npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

creatox-ui-kit

v0.7.3

Published

Web-native, object-centric React UI kit. Tailwind v4 tokens, CSS-first components, minimal JS runtime.

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(…) }, рост Textareafield-sizing, действия строк — group-hover и :focus-within. Поповеры позиционирует CSS anchor positioning.

Поэтому usePopover() не хранит состояния, у Tree нет набора открытых узлов, у локальных табов нет useState.

useEffect в ките семь, и все — там, где у платформы нет декларативного входа: showModal() у Dialog, showPopover() у области тостов, подписка на beforetoggle у списков и у Popover с deferaria-expanded нет способа прочитать состояние поповера из разметки), возврат подсветки активного пункта после того, как React пересобрал список, и перевод фокуса на день календаря после стрелки — ячейки ещё нет в DOM в тот момент, когда обработчик решил, куда идти.

Установка

npm i creatox-ui-kit

React 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 этого не решает.

Состав

LayoutRoot, 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-tags

GitHub Actions собирает, проверяет типы, убеждается, что стилевой файл не пустой, публикует в npm с provenance и выкладывает витрину на GitHub Pages.

Лицензия

MIT