creatox-ui-kit
v0.7.3
Published
Web-native, object-centric React UI kit. Tailwind v4 tokens, CSS-first components, minimal JS runtime.
Maintainers
Readme
creatox-ui-kit
UI kit для React на Tailwind v4. Состояние по возможности отдано браузеру, поэтому JS в рантайме мало.
Собран по документу «Web-Native Object-Centric UI». Готовых страниц и дашбордов здесь нет — примитивы, макеты и доменный слой.
Что делает платформа
Tree — это <details>, Dialog — <dialog>, Popover и Menu — атрибут popover, локальные Tabs — radio-группа, тултип — ::after { content: attr(…) }, рост Textarea — field-sizing, действия строк — group-hover и :focus-within. Поповеры позиционирует CSS anchor positioning.
Поэтому usePopover() не хранит состояния, у Tree нет набора открытых узлов, у локальных табов нет useState.
useEffect в ките семь, и все — там, где у платформы нет декларативного входа: showModal() у Dialog, showPopover() у области тостов, подписка на beforetoggle у списков и у Popover с defer (у aria-expanded нет способа прочитать состояние поповера из разметки), возврат подсветки активного пункта после того, как React пересобрал список, и перевод фокуса на день календаря после стрелки — ячейки ещё нет в DOM в тот момент, когда обработчик решил, куда идти.
Установка
npm i creatox-ui-kitReact 19.2 или новее в peer-зависимостях.
Без Tailwind достаточно готового файла (~10 КБ gzip):
import 'creatox-ui-kit/styles.css'Если Tailwind v4 уже свой — лучше так, тогда токены и утилиты общие и дубля нет:
@import 'tailwindcss';
@import 'creatox-ui-kit/theme.css';
@source "../node_modules/creatox-ui-kit/dist";Использование
import { Root, Split, ObjectHeader, Status, Section, Table, Panel } from 'creatox-ui-kit'
import 'creatox-ui-kit/styles.css'
export function App() {
return (
<Root>
<Split>
<nav data-cx-collapsible="true">…</nav>
<main>
<ObjectHeader
type="Service"
name="booking-api"
status={<Status tone="success">Healthy</Status>}
/>
<Section title="Deployments">
<Table>…</Table>
</Section>
</main>
<Panel title="Related">…</Panel>
</Split>
</Root>
)
}<Root> оборачивает приложение один раз: типографика, контракт фокуса и именованный контейнер page, к которому обращаются макеты.
Токены
Все токены — в одном блоке @theme. Тёмная тема переобъявляет те же переменные, поэтому dark: нет ни в одном компоненте, а перетемизация сводится к переопределению переменных:
:root {
--color-accent: oklch(55% 0.19 25);
--color-accent-hover: oklch(49% 0.19 25);
}import { applyTheme } from 'creatox-ui-kit'
applyTheme('dark') // 'light' | 'dark' | 'auto'applyTheme ставит атрибут на <html>, а не на <Root>: диалоги и поповеры живут в top layer, вне вашего дерева.
Имена по роли, а не по виду: bg-canvas, bg-raised, text-fg, text-fg-muted, border-border. Размеры шрифта тоже — text-micro, text-meta, text-ui, text-reading, text-title, text-identity, text-display, базовый 13px. Отступы — обычная шкала Tailwind.
Цвет обводки называется border (border-border, border-border-strong); line — то же значение под прежним именем. Двух имён не было бы, но несуществующий цвет обводки в Tailwind не ошибка: утилита просто не порождается, остаётся голый border, и хайрлайн выходит цветом текста — чёрной рамкой, о которой сборка молчит. На всякий случай в .cx-root объявлен border-color по умолчанию, так что промах теперь даёт хайрлайн, а не плашку.
Акцент нейтральный, а не синий. Так делают оба продукта на этом ките — главное действие они рисуют почти чёрным, — и так же говорит собственное правило кита: иерархию несут три уровня контраста, а не цвет. Синим остаётся --color-info; если цветное главное действие всё-таки нужно, переопределяются пять переменных выше. Статусные цвета не тронуты.
Токены живут в неймспейсах Tailwind
Не «просто переменные». Tailwind порождает утилиту на каждое значение в неймспейсе, который он знает, — и весь смысл в том, чтобы попадать в эти имена, а не изобретать свои:
| Неймспейс | Что получаете |
| --------------- | ------------------------------------------------------------------------------- |
| --color-* | bg-canvas, text-fg-muted, border-border-strong |
| --text-* | text-ui, text-control-md, text-field-sm |
| --spacing-* | h-control-md, size-control-sm, px-field-md, px-button-lg, size-choice |
| --radius-* | rounded-control |
| --container-* | max-w-app и варианты @max-narrow/page:, @max-medium/page: |
| --ease-* | ease-snap |
Высоты контролов раньше звались --size-control-md. Неймспейса --size-* в Tailwind нет — это была обычная переменная, каждое обращение к ней превращалось в h-[var(…)], и продукту, расширяющему кит, не доставалось ничего. Теперь они в --spacing-*, и h-control-md существует сам собой.
Длительности — исключение: неймспейса для transition-duration у Tailwind нет, поэтому duration-instant, duration-snap и duration-enter объявлены через @utility вручную.
Размерный ряд — функциональная утилита
@utility control-* {
--cx-control-pad: --value(--spacing-field-*);
block-size: --value(--spacing-control-*);
font-size: --value(--text-control-*);
padding-inline: var(--cx-control-pad);
}--value() разрешает суффикс по неймспейсу, поэтому control-md сам берёт --spacing-control-md, --text-control-md и --spacing-field-md. Добавили у себя --spacing-control-xl вместе с парой к нему — control-xl заработает, кит об этом размере знать не обязан. То же у button-*, у которого своя, более просторная горизонтальная набивка.
Блок токенов объявлен как @theme static. Это библиотека: при её сборке Tailwind не видел вашей разметки и выбросил бы всё, чего не использовал сам кит, — тогда var(--color-info-surface) в вашем коде оказался бы пустым.
Классы склеивает cx, и брать нужно именно его
import { cx } from 'creatox-ui-kit'
const cls = cx('w-full', isWide && 'max-w-none')Внутри — clsx плюс tailwind-merge, то есть последний класс побеждает по-настоящему, а не по воле порядка правил в таблице стилей. Без слияния className="h-12" на контроле оставлял бы в атрибуте и control-md, и h-12, и кто из них сработает, решал бы Tailwind, а не вы.
Свой cn из shadcn сюда не подходит. tailwind-merge группирует классы по свойству и знает только штатные утилиты; control-md, px-field-md, rounded-control, text-control-lg и duration-snap — наши, и чужой мержер оставит их обе стороны конфликта. В cx эти группы объявлены, включая то, что control-* и button-* задают три свойства сразу и вытесняются любым из h-*, px-*, text-*, size-*.
Варианты компонентов описаны через cva; она же экспортируется, если захотите собрать свой рецепт на тех же правилах.
Плотность
Второй размерный ряд — не четвёртое значение size, а атрибут на корне: палец — свойство устройства, а не отдельной кнопки.
<Root density="touch">…</Root>import { applyDensity } from 'creatox-ui-kit'
applyDensity('touch') // 'compact' | 'touch'compact — 32 / 36 / 40 px, десктопный ряд. touch — 40 / 48 / 56, под палец. Меняются только переменные, поэтому разметка не знает о плотности: size="md" сам становится 48px, а вместе с высотой едут кегль, внутренние отступы, скругление, размер чекбокса и переключателя. Подпись поля, само поле и текст ошибки под ним остаются одним рядом, потому что читают те же токены.
Форма контролов — своя переменная, отдельно от панелей и диалогов:
:root {
--radius-control: 999px; /* кнопки и поля пилюлями, панели прежние */
}Для разовой правки под палец есть вариант touch: — тот же атрибут, что и у переключателя. Компоненты кита им не пользуются: они читают токены, которые плотность и переопределяет, в этом весь приём.
<div className="gap-2 touch:gap-4">…</div>Состояние в атрибутах
Всё, что меняется во времени, компонент кладёт на элемент: data-selected, data-interactive, aria-busy, aria-invalid, aria-current. Стили читают атрибут, поэтому строка классов от состояния не зависит, а выделенную строку можно перекрасить из своего CSS, не получая пропс:
[data-selected] { … }Варианты, размеры и тона остаются пропсами: они задаются один раз при вставке и во времени не меняются.
Движение
--duration-instant (60ms) — отклик на нажатие, --duration-snap (120ms) — смена состояния, --duration-enter (180ms) — приход и уход слоя. Те же имена есть как утилиты, чисел в разметке быть не должно.
prefers-reduced-motion: reduce режет длительности в одном месте, но не отменяет переходы: display и overlay продолжают ехать через allow-discrete, иначе поповер застрянет в промежуточном состоянии.
Появление и уход Popover и Dialog — переход с @starting-style, без ключевых кадров и без JS.
Все интерактивные элементы несут active:. На телефоне hover не существует, и без этого нажатие не видно, пока не приедет новое состояние.
Чего кит не обещает
Tree — это вложенные <details>, а не role="tree". Роль дерева требует стрелок по узлам, сворачивания влево-вправо, перехода по первым буквам и уровня у каждого узла; объявить её и не реализовать хуже, чем не объявлять. <details> озвучивается правильно и работает с поиском по странице.
Menu роль menu объявляет и держит: стрелки, Home/End, открытие стрелкой с триггера. Обработчик один на список, а не по одному на пункт.
Раскрывающихся нативных контролов в ките не осталось. Select и Combobox рисуются сами: у <select> попап в каждой платформе свой, внутрь него не попадает ни оформление, ни вторая строка с пояснением под пунктом — а именно она и нужна форме настроек. <datalist> хуже: три движка, три поведения.
Отдано платформе всё, что действительно тяжело. Список — это [popover], поэтому top layer, закрытие по клику мимо и по Escape — браузерные, и список не обрезается прокручиваемым предком, на чём обычно и ломаются самодельные дропдауны внутри панели. Позиционирует CSS anchor positioning. Своим кодом остаётся только то, чем это всегда и было: какой пункт активен и что с ним делают стрелки.
Фокус не уходит с триггера, активный пункт называется через aria-activedescendant. Select принимает и options, и прежние <option> детьми — разметка в существующих формах не меняется. Набор по первым буквам работает, как у нативного. У Combobox фильтр — вхождение подстроки, а не префикс: тот, кто набрал «west», ищет eu-west-1.
Список рисуется целиком и лежит в документе всегда: закрытый [popover] — это display: none, а не размонтированное поддерево. Открытие поэтому ничего не строит, а состояние внутри переживает закрытие. Цена — рендер: каждая нарисованная строка пересобирается на каждый ввод. Отсюда две вещи: Combobox рисует не больше сотни совпадений и говорит об этом строкой внизу списка, а активный пункт двигается атрибутом, без участия React. Практический порог — до трёх сотен значений Select, дальше Combobox с фильтром; список, который заведомо длиннее, продукт отдаёт постранично.
Popover содержимое тоже держит в документе. Если внутри что-то тяжёлое — календарь это сотня узлов и сорок четыре кнопки на каждое поле — есть defer: содержимое уезжает в скрытый <Activity>, React строит его заранее с пониженным приоритетом и сохраняет состояние между открытиями. Tabs остаются как есть: панели переключает CSS, без единой строчки JS, и ленивость это сломает — тяжёлую вкладку продукт ленит сам.
<input type="date"> кит не оборачивает нигде, и это единственный нативный контрол, от которого пришлось отказаться: Firefox, Chrome и Safari рисуют три разных виджета, ни один не берёт оформление продукта, а всплывающий календарь не похож ни на что другое на экране. Вместо него Calendar — сетка месяца — и DatePicker, та же сетка за кнопкой в поповере. Занятые дни помечаются через isDisabled; нативный этого не умеет вовсе.
DateRangePicker — два конца сразу, и переключатель того, что покрывает один клик. Переключатель здесь и есть суть: произвольный отрезок — это честные два клика, но «эта неделя» и «этот месяц» в голове одна вещь, и их незачем набирать двумя концами. Days / Week / Month, где границу недели считает локаль, а не мы. Тот же режим доступен у Calendar через mode="range" и granularity.
Время — TimePicker, одно поле, а не три селекта. Три селекта — это время, собранное из того, что было под рукой: прочитать его значит прочитать три коробки и сложить, а поменять — три отдельных действия. Здесь одно поле с 18:30 и списком за ним. Парсер намеренно нестрогий: 1830, 18.45, 18 и 6:30 pm дают одно и то же.
24 часа по умолчанию. Не по локали браузера — иначе контрол читается по-разному в тесте и на проде. hour12 включается явно; значение наружу в любом случае HH:MM в 24 часах.
Остальное локаль решает сама: названия месяцев и дней, порядок дней, первый день недели и как называется «до полудня» — всё из Intl. Календарь с зашитым понедельником неверен в США, с зашитым английским — везде.
Дата со временем — DatePicker и TimePicker рядом: в каком порядке они читаются, знает форма, а не кит.
Checkbox, Radio и Toggle кладут className на строку целиком, а не на сам бокс, — поэтому выравнивание остаётся за продуктом и делается одним классом: items-center центрирует бокс по всей строке, mx-auto ставит его посередине ячейки. По умолчанию бокс садится на базовую линию первой строки метки, а не на её середину: так он попадает в текст при любом размере шрифта и в touch-плотности, где сам бокс крупнее. Флажок без метки — просто элемент: он центрируется тем, во что его положили.
PinInput — один настоящий input, а не шесть. Ряд отдельных полей ломает вставку из буфера, автозаполнение кода из SMS и выделение целиком; клетки под значением нарисованы и состояния не хранят.
Toaster — единственное место с хранилищем: сообщить «сохранено» хочет мутация где-то в продукте, а не компонент с местом в дереве. Область — popover="manual", чтобы попасть в top layer: тост, поднятый поверх открытого <dialog>, иначе окажется под ним, и никакой z-index этого не решает.
Состав
Layout — Root, Stack, Inline, Grow, Cluster, Grid, Split, Sidebar, Section, Container, ScrollArea
Примитивы — Text, Code, Link, Icon, Button, IconButton, Field, Input, Calendar, DatePicker, DateRangePicker, TimePicker, Textarea, Select, Combobox, InputGroup, PinInput, Checkbox, Radio, Toggle, SegmentedControl, Slider, Progress, Badge, Status, Avatar, Separator, Skeleton, EmptyState, Popover, Menu, MenuItem, Dialog, Sheet, Toaster, Panel, Table, Th, Td, Tr, RowActions, SortButton, List, ListItem, Pagination, Tree, TreeNode, TreeLeaf, TabNav, Tab, Tabs, KeyValue
Доменные — ObjectRef, ObjectHeader, Breadcrumbs, RelationshipList, ActivityStream, ObjectActions
Иконок в поставке нет: Icon — обёртка над вашим <svg>.
Шторка
Sheet — это телефон. На широком экране контекст открывают Panel или Dialog: там есть куда положить вторую колонку, и шторка, выехавшая снизу на 27 дюймах, закрывает работу вместо того, чтобы её дополнить. Внизу экрана содержимое оказывается под большим пальцем — ради этого всё и затевается, и это довод, который есть только у телефона.
Высот три, и четвёртой нет. Какую взять, решает то, что внутрь кладут, — при открытии, один раз:
| Детент | Высота | Что на ней делают |
| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------ |
| peek | --cx-sheet-peek, 30dvh | Шторка объявляет о себе. Заголовок, пара строк и действие; объект под ней всё ещё читают |
| half | --cx-sheet-half, 60dvh | Рабочая высота: список видно целиком, и то, к чему он относится, — тоже |
| full | --cx-sheet-full, 92dvh | Всё содержимое. Полоска страницы сверху остаётся нарочно — иначе шторка становится экраном, и объект потерян |
60, а не 50: свои же ручка, заголовок и футер съедают у шторки около 8rem, и на честной половине экрана содержимому достаётся меньше половины. Значения — доли вьюпорта, а не длины: остановка должна отвечать на вопрос «сколько страницы осталось», а 24rem — это peek на десктопе и весь экран на телефоне. Продукт переопределяет все три в одном месте.
Менять высоту руками пользователь не может, и это решение, а не упущение. Тянущаяся шторка обязана мерить себя и писать высоту обратно в раскладку с частотой кадров, а покупает она этим позицию, к которой никто не возвращается: «та высота, где я её оставил» — не то, что помнят. Выбрать правильную из трёх при открытии — то же самое решение, принятое один раз и бесплатно во время работы. Поэтому в компоненте нет ни одного слушателя: ни pointer-событий, ни ResizeObserver, ни замеров. Открытие стоит одного showModal(), высоту дальше держит CSS.
Закрывают ручкой, крестиком в шапке, нажатием на затемнение и Escape. Первые три выключаются dismissible={false}, Escape остаётся браузерным. Ручка закрывает, а не сворачивает: это действие, которого от шторки ждут, и приходит оно нажатием — свайп вниз в браузере уже занят pull-to-refresh, адресной строкой и жестом «назад».
Ручка спрятана от скринридера (aria-hidden): она делает ровно то же, что крестик рядом с заголовком, а «Закрыть, Закрыть» подряд описывает интерфейс, которого нет. Имя носит крестик.
Высота не анимируется вообще. Она ставится при открытии и стоит, а ездит translate — свойство композитора: браузер поднимает шторку, ни разу не перекладывая список внутри неё. На этом сходятся все живые реализации: Vaul двигает translate3d и отдельно рассказывает, сколько кадров потерял, пока анимировал то, из-за чего пересчитывались дети.
Довод против translate, который кит приводит у Dialog, здесь не работает. Он про шторку, у которой трансформ — состояние покоя: тогда футер и правда живёт за нижним краем экрана. У нас покой — это translate: 0, смещено только закрытое состояние, поэтому застрявший приход показывает шторку, которая ещё едет, а не шторку с обрезанными кнопками. А уход, где застревание и правда заперло бы пользователя, ведёт JS с таймером позади.
Смена detent на уже открытой шторке — мгновенная, без перехода: это раскладка, а раскладку мы анимировать не хотим. Меняют её редко и по своей воле, так что это цена, которую видно один раз.
Закрывающаяся шторка высоту не меняет вовсе — компонент держит ту, что была. Продукт обычно хранит остановку и открытость в одном состоянии (open={picked !== null} detent={picked ?? 'half'} — так это и пишут), поэтому на кадре закрытия проп прилетает другой, и без этой памяти шторка на краю сначала прыгала бы на половину экрана и только потом уезжала.
Появление и уход — это она же: у закрытой шторки высота нулевая, у открытой — её остановка, поэтому она вырастает из нижнего края и уезжает в него же. Стартовый кадр прихода задаёт @starting-style — тут действительно ни JS, ни ключевых кадров.
С уходом сложнее, и это единственное место, где кит вмешивается. close() вынимает элемент из верхнего слоя в тот же момент, поэтому анимации ухода просто негде играть. У платформы на это есть ответ — свойство overlay с allow-discrete, браузер откладывает удаление до конца перехода, — но реализует его только Chromium: в Safari и Firefox шторка исчезает в том же кадре, в котором закрыта. Именно так это и выглядит: «открытие плавное, закрытие резкое».
Поэтому уход играет на ещё открытом диалоге. Компонент ставит data-closing, CSS уводит шторку за нижний край, и close() вызывается по transitionend, когда убирать с экрана уже нечего. Ровно так же делает Radix: элемент остаётся в дереве с пометкой состояния, пока анимация не доиграет. Один атрибут и один слушатель, оба живут ровно столько, сколько идёт анимация. Есть и страховка: таймер на длительность, прочитанную с самого элемента, — переход может не случиться вовсе (prefers-reduced-motion, выключенные продуктом анимации, вкладка в фоне), и шторка обязана закрываться и тогда.
Escape по той же причине перехвачен: нативно он закрывает диалог мгновенно. cancel отменяется, наверх уходит onClose, и дальше — тот же путь.
Уход — не появление задом наперёд, и кривые у них разные. Шторка приходит издалека и останавливается: --ease-snap, замедление в конце. Уходит она из состояния покоя и заканчивает у края экрана, значит должна разгоняться, — --cx-sheet-ease-exit. С той же замедляющей кривой уход стартует на полной скорости, и именно это читается как «слишком резко».
Длительности две и они разные: --cx-sheet-enter — 400ms, --cx-sheet-exit — 250ms. Причина в расстоянии: --duration-enter отмеряет приход слоя, который уже на месте, а шторка пересекает почти весь экран, и 180ms на 700 пикселей — это кадр в начале, кадр в конце и нечего проследить глазом. Причина разницы между ними — внимание: приходящая шторка и есть событие, ради неё лишняя сотня миллисекунд, а уходящая мешает, и от неё требуется только быть видимой, а не мгновенной. Кривые — --cx-sheet-ease-enter и --cx-sheet-ease-exit, пара emphasized из Material: cubic-bezier(0.05, 0.7, 0.1, 1) на торможение и cubic-bezier(0.3, 0, 0.8, 0.15) на разгон.
Токены --cx-, а не четвёртый и пятый --duration-*: ими пользуется только шторка, и новыми ступенями шкалы для остального кита они не являются. prefers-reduced-motion режет их вместе со всем остальным.
Адаптивность
Макеты смотрят на доступное место, а не на ширину окна, поэтому Split внутри узкой панели ведёт себя так же, как на широкой странице. У Grid колонки выводятся из min через auto-fit, Sidebar переносится сам за счёт flex-basis, а Split при нехватке места уводит контекстную область под объект и затем сворачивает навигацию.
Браузеры
Базовая линия — Chrome 111+, Safari 16.4+, Firefox 128+, это требование Tailwind v4.
CSS anchor positioning появился в Chrome 125, Safari 26 и Firefox 147. Где его нет, Popover не уезжает в центр экрана, а прижимается к нижнему краю окна шторкой — ветка заложена в @supports. Оттуда же matchAnchor: ширина по триггеру считается через anchor-size() и молча не применяется там, где anchor positioning недоступен.
showPopover({ source }) — Chrome 133. Им Combobox объявляет поле своим инвокером, чтобы клик по полю не считался кликом мимо списка. Аргумент, которого движок не знает, просто игнорируется: список останется открываемым с клавиатуры и вводом, но клик по полю при открытом списке его закроет.
@starting-style и transition-behavior: allow-discrete — Chrome 117, Safari 17.4, Firefox 129. Без них слои появляются мгновенно.
overlay — только Chromium. На нём держится уход Popover и Dialog: где свойства нет, слой пропадает в том же кадре, в котором закрыт, и анимация ухода не видна. Sheet на него не полагается — он играет уход на ещё открытом диалоге и закрывается по transitionend.
field-sizing: content в Textarea деградирует до resize: vertical.
Разработка
npm install
npm run dev # витрина на localhost:5180
npm run typecheck
npm run lint # oxlint
npm run format # oxfmt
npm run build # dist/index.js + dist/styles.css + dist/theme.cssВитрина (demo/Workbench.tsx) — не галерея компонентов, а собранное представление объекта: только так видно, работают ли иерархия, плотность и связи.
Релиз
Публикация привязана к тегу, поэтому опубликованная версия всегда совпадает с записанной в git:
npm version patch && git push --follow-tagsGitHub Actions собирает, проверяет типы, убеждается, что стилевой файл не пустой, публикует в npm с provenance и выкладывает витрину на GitHub Pages.
Лицензия
MIT
