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

@charcuterie/ui

v4.9.0

Published

The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.

Readme

@charcuterie/ui

The components. Look from @charcuterie/tokens, state from @charcuterie/logic, and ARIA that Playwright and an AI agent can actually drive.

M3 ships the P0 pure-presentation set: Spinner, Skeleton, Button, IconButton, Badge, ProgressBar, ProgressCard, EmptyState, Card, LiveStatusIndicator, MediaTile — plus VisuallyHidden, which the first three of those need. M4 adds the overlays: Modal, Popover, Tabs.

M5 adds two, and they came from the first consumer rather than from the plan — the rule:

  • Alert — rip-deck spells this shape four times, two of them carrying a byte-identical TONE_CLASS map of hardcoded hexes. It is the app's largest single duplication and no P0 component is a banner.
  • SegmentedControl — SinglePicker + RovingFocus with the panels taken away, which is a composition that could not be expressed until Tabs moved onto SinglePicker.

M6a ships the nine P1 components: Select, Field, Accordion, LogViewer, Menu, Tooltip, SortableTableHeader, Toast (with ToastRegion), and FileDropZone. Twenty five components in all. Four of them changed rules this package had already written down — a menu is named by its trigger, an accordion panel is a group rather than a landmark, Select owns no state at all, and not every boolean is a state kind. Full write-up: M6a handoff.

The link family — TextLink, ButtonLink, and UnstyledLink — closes the gap that had seven repos hand-rolling a ← back-link, plex-channels navigating with <button onClick={() => navigate(…)}>, and mux-magic reloading the page through 14 raw <a href>. Buttons are for on-page actions; links are for navigation, and both of these render a real <a href> so middle-click, ctrl-click, open-in-new-tab and copy-link-address work. The first two differ in paint, not semantics. UnstyledLink owns no paint and preserves an established app-owned treatment while still using the same router seam (decision). The router is injected rather than depended on — RouterLinkProvider at the app root, with @charcuterie/ui/react-router as an optional subpath adapter and a plain <a> when nothing is provided. Recipe: Guides/Routing in Storybook.

Board is the first component whose own operation is a write, and the first to declare two nested containers. Lanes with honest counts, priority bars, per-card footers for a live run line, real empty states and a + n more overflow — laneLayout="columns" places workflow lanes beside each other, while laneLayout="rows" makes every project pool a horizontal band with a responsive card grid. Each lane's box decides whether a card is two lines, one line, or a card. There is no media query in it, because a lane in a three-up board is ~500px on a maximised 1600px window and a browser at 175% zoom reports ~860 effective pixels for a 1500px one. Moving a card takes no drag-and-drop dependency: one handle per card, a Menu of the other lanes as the primary path, Pointer-Event dragging at 1.4 KB gzip beside it (decision).

LaneTimeline is the first component that folds on a measurement of its own box. One horizontal lane per group across a date axis, with anything lasting more than a day drawn as a bar — the shape a month grid cannot show, and the only one that makes load asymmetry visible, because overlapping items pack into stacked rows and a busy lane is literally taller. end is inclusive, said four times over, because an off-by-one on a date range is invisible in both directions. The Narrow View abandons the axis rather than panning or squeezing it. And the fold is measured rather than queried for a reason no other component here has: the question is not how wide the box is but how wide one day is, which is the width divided by a number that lives in the data — 30 columns in 900px is a readable month and 365 columns in the same 900px is a smear (decision).

DropRail is the move control for a page Board's Menu cannot serve. A menu of the other lanes is right at three lanes; it is wrong at thirty-four groups, where it becomes a scrolling list inside a popup and a drag becomes a scroll with the button held down. The rail pins every destination to the top for the length of a move — no travel, nothing off screen — as a listbox driven by pointer, tap or the arrow keys, all committing through one onPick. It reads event.buttons to tell a drag from a tap, so a host says only whether a move is in flight. It holds the same line on dependencies: elementFromPoint over its own chips is the only geometry a rail needs. Not to be confused with Rail, the app shell's side landmark — FileDropZone is the precedent for the prefix carrying the distinction.

ReorderList is the same shape a fourth time, so it is the last time. A vertical list put in a different order by two buttons or by dragging a handle. Docket had written it twice — a subtask checklist on HTML5 draggable, which does not fire on touch at all, and a phase queue with the buttons and no drag — and Board had written it once correctly. So this component calls useBoardDrag with a single registered lane rather than forking its geometry, the way VirtualizedGrid calls useAdaptiveColumns; the snapshot, the threshold and the pointer capture exist once. The two move buttons are the PRIMARY path and the drag is the enhancement, per WCAG 2.5.7 — a host that draws no handle is supported. It renders no row, no handle and no buttons: renderItem receives moveBy and gripProps, which is what lets one component serve three CSS grids that have nothing in common.

BadgeButton is a Badge you can press, and it came from a consumer rather than from a plan: QueuePilot has six pill-shaped controls — per-entry setting tags, an Edit chip, two start-point chips, a group chip, a pool's Exclude chip — that were hand-rolled <button className="badge …"> because Badge is a <span>. A sibling component, exactly as ButtonLink is to Button: one paint through useBadgeShape, compared as computed styles in the test, and a real <button> for focus, Enter, Space, :disabled and a role a screen reader announces. onClick is required, so a pill nobody can press is a Badge by construction (decision).

ActionTiles is the tile that presses, and it is the case the choice-tile record named as out of scope: mux-magic's "Pick a tool" tiles are <a href> and points-market's are <Link>, and a link is not a radio. That record's test was only the box changes, and here it is false — no role to borrow, no roving tabindex, no selection-follows-focus, no read-only, no pending value — so the two share the box and nothing else, through tileStyles.ts, and the test compares computed styles rather than class names. It exists because QueuePilot's queue-type chooser was two Buttons with height: auto: a Button carries no block padding at all, so the override computed to padding: 0 down the block axis and shipped a card whose title sat flush against its top border. Nothing could report it — the class really was in the DOM, tsc never reads the CSS, and unstyled markup passes axe (decision).

TimecodeInput is the first field the fleet could not write down twice the same way. QueuePilot is adding "play only this section of a video", and the printing half of a timecode already exists five times — queuepilot's clock and toClock, castkit's formatTime, and mux-magic's two modal formatters — with three different answers to whether the hour is shown and two to whether the minute is padded, so the same position renders differently in two panels of one app. The reading half exists nowhere. It is a text field rather than an <input type="time"> for the reason Select is deprecated and Slider is a div: the platform widget is OS-painted, and it cannot carry a millisecond anyway. The grammar is ordered, anchored and total — 90 is ninety seconds, 90:00 is ninety minutes, and 1:90 is refused by name rather than carried into 2:30, which is the 1:60 trap mux-magic wrote down in 2023. isRange edits a section whose two ends are independently optional; an inverted section swaps, and a zero-length one is refused, because a section with no length plays nothing (decision). RangeSlider is the two-thumb range, and it exists because the Slider record said one would be a different widget: QueuePilot plays only a section of a video, so a user drags two handles over a film's runtime and reads the result beside a TimecodeInput. Slider puts role="slider", the tab stop and the pointer target on the track, and two values cannot live on one accessible object — so here the role moves to the thumbs, the track becomes a role="group" carrying the label, and one tab stop becomes two. That is why it is a component rather than an isRange mode: both halves of the test that made Combobox's isMultiple and DatePicker's isRange modes — the role does not change, and the shared part is everything — are false here. The thumbs clamp rather than swap, so the handle under the pointer keeps its identity and each thumb reports the other as its own aria-valuemin/aria-valuemax. What is genuinely shared is the arithmetic (Slider/sliderValue.ts, called rather than copied) and the bar (sliderStyles.ts, the same extraction tileStyles.ts is), compared as computed styles in the test (decision).

The unified app shell adds four: Shell, Header, Rail, Main — the fleet's largest single duplication, where ten of twelve UI repos hand-roll the page chrome and three of them have a file named AppShell.tsx (two of those headers are a byte-identical class string, arrived at independently). One contentWidth on Shell feeds both the header row and the content column, isSticky writes position and z-index together, and a Rail collapses by restyling rather than by rendering itself twice. Decision; Shell's docs page carries three copy-wholesale templates.

Install and wire

import { Button, Card } from "@charcuterie/ui"
@import "tailwindcss";
@import "@charcuterie/tokens/theme.css"; /* colours, type ramp, radii, motion */
@import "@charcuterie/tokens/fonts.css"; /* Baloo 2, Outfit, Victor Mono */
@import "@charcuterie/ui/styles.css";    /* the four looping affordances + `.charcuterie-scrollbar` */

@source "../node_modules/@charcuterie/ui/dist";

That last line is not optional. Tailwind v4 scans source text for complete class strings, and it does not scan your dependencies by default — without it every component renders unstyled, with no error.

Both package names, and the CSS line above is why. The token types and values are re-exported at @charcuterie/ui/tokens, so your TypeScript only ever names one package — but the stylesheet is imported from @charcuterie/tokens directly. A ./tokens.css export pointing inside this package resolved to nothing on the first real consumer, because a hoisting linker puts @charcuterie/tokens at the project root rather than in here, and a CSS @import that misses fails silently: no error, no utilities, an unstyled app.

The testing gates are at @charcuterie/ui/testing.

Detect a new deployment

useDeploymentUpdate detects that a static SPA has been replaced. It works with createStaticHandler from @charcuterie/server, which provides a no-cache build marker and an SSE marker stream by default.

const { isUpdateAvailable, reload } = useDeploymentUpdate()

return isUpdateAvailable ? (
  <Button onClick={reload}>Update this app</Button>
) : null

The hook never reloads without the user's action. A page can have unsaved state. If the app already owns an SSE connection, set isEventSourceEnabled: false and call checkForUpdate() after that source reconnects.

While the packages are unpublished: three lines, and you need all three

A consumer links by portal: until this publishes (decision). M5 spent an hour on the second and third of these, so M5b and M6 do not have to:

// the app package
"dependencies": {
  "@charcuterie/tokens": "portal:../../../charcuterie/packages/tokens",
  "@charcuterie/ui": "portal:../../../charcuterie/packages/ui"
}

// the PROJECT ROOT — `ui` declares its siblings as `workspace:*`, and that
// descriptor cannot resolve outside charcuterie's own workspace. Bare keys, not
// `@charcuterie/ui/@charcuterie/tokens`: the scoped form yields a different
// locator string from the app's own dependency on the same directory, and Yarn
// rejects the pair as conflicting.
"resolutions": {
  "@charcuterie/logic": "portal:../charcuterie/packages/logic",
  "@charcuterie/tokens": "portal:../charcuterie/packages/tokens"
}
// vite.config.ts AND vitest.config.ts
resolve: { dedupe: ["react", "react-dom"] }

That last one is the expensive one. A portal: is a symlink, and Node and Vite both resolve a symlinked module from its real path — so a component living in this package resolves its own react by walking up from here, landing on charcuterie's copy while your app renders with yours. Every component with a hook fails at once with:

TypeError: Cannot read properties of null (reading 'useRef')

which mentions neither symlinks nor React identity. Keep the line after publish: it costs nothing with one copy, and it is the difference between a working yarn link session and an hour of confusion.

How a component is put together

| Layer | Where it comes from | | --- | --- | | Colour | intentStyles.ts — six intents x four appearances, written out in full | | Size | controlStyles.ts — h-(--control-height-md), so [data-density] decides | | Type | text-sm/text-md/text-lg, which are ours: theme.css bridges --text-* onto the density-scaled --font-size-* | | Motion | styles.css, at --duration-loop-*, switched off under prefers-reduced-motion | | Scrollbar | styles.css — add charcuterie-scrollbar to any scrolling element for a token-tinted bar (rounded thumb on Chromium/Safari; thin standard match on Firefox) that flips with [data-scheme] | | Accent edge | Card/cardAccentEdge.ts — <Card accentEdge={{ categorical }} \| {{ color }}> draws a bar down the leading edge from a pseudo-element that inherits the card's radius. A border cannot follow a corner, which is why three apps had a square bar on a round card | | State | @charcuterie/logic — useUniqueId for label wiring, useStatus for MediaTile's three image states |

Class names are never interpolated. `bg-intent-${intent}-solid` generates nothing at all and fails silently, so intentStyles.ts is 48 literals rather than a loop, and tailwindCandidates.test.ts compiles every literal in the package through the real Tailwind and fails on any candidate Tailwind cannot generate.

Tests

yarn vitest run --project ui         # Node: class maps, status switches, clamping, boundaries
yarn vitest run --project ui-dom     # chromium: Component.test.tsx — behaviour, keyboard, ARIA
yarn vitest run --project storybook  # chromium: every story renders, axe at test: "error"

Stories are demos and carry no assertions (decision, which supersedes M3's). The DOM half lives in Component.test.tsx beside the component, and each test mounts the composed story through run() rather than re-assembling the component — so there is still only one rendering stack, and the subject of a test is the story a reader sees.

Every component's test calls expectAgentDrivable(canvas, { role, name }), which is the same query an agent will write; data-testid appears nowhere in this package and a test enforces that.

Axe runs once per mount, when run() resolves — i.e. before the test has clicked anything. A state you had to drive to reach (an open dialog, a shown popover) audits itself with expectNoAxeViolations(canvasElement).

Two rules about the docs panel are tests rather than conventions, because both fail invisibly: storyControls.test.ts (a prop typed from another package needs an explicit argTypes entry, or Storybook renders a {} textarea for it) and mdxReferences.test.ts (an .mdx may not reference a story that no longer exists — of resolves at runtime, so a rename breaks only the rendered page).

Adding a component

  1. src/<Name>/<Name>.tsx, .stories.tsx, .mdx, .test.tsx — siblings, matching mux-magic. The story shows it; the test drives it. Neither does the other's job. The story's title is Components/<Group>/<Name>, and the group is one of Actions (it does something), Controls (it takes a value), Overlays (it renders over the page), Layout (it arranges other things), Data (it shows a set) or Feedback (it reports state). Nothing composable goes at the top level; a helper that is not a component — VisuallyHidden, Scrollbar — is Utilities/<Name>. Group order in the sidebar comes from packages/docs/.storybook/preview.tsx, and an unlisted group sorts last instead of silently displacing Actions (decision).
  2. Colours from intentStyles.ts, sizes from controlStyles.ts. No hex, no *-slate-* (a test checks).
  3. The five stories: Playground, AllVariants, AllStates, Responsive (three container widths, via ContainerBoard), Interactive (the complete keyboard path). Playground is the only one that carries parameters: playgroundParameters. The addon panel is closed on every story in this Storybook, and that line is how the one story whose args drive a single instance opens it again. The other four render their own matrix, so a control moves at most one cell of the board (decision).
  4. Export from src/index.ts — the one sanctioned barrel. Components import each other directly, never through it.
  5. If it is an overlay, portal it to the body — FloatingPortal, through OverlayPanel, never a hand-rolled <dialog> + showModal() or popover="manual". The top layer wins paint order but is still laid out in place, so a transform or overflow: hidden ancestor still clips a fixed panel; a portal to document.body has no ancestor to be clipped by (decision, superseding M4's). The old objection stands answered rather than waved: useRole writes aria-controls/aria-labelledby across the boundary, and the stories scope panel queries to body.
  6. If it is built on a registering kind, look at its first paint. Members register from effects, so before those run a RovingFocus has no active value and a SinglePicker / VisibilityGroup has no selected key — only a pending one. Two of M4's four bugs were exactly that, and the isolated story runner saw neither — yarn smoke:storybook did.
  7. If it declares @container, every StoryCell holding it needs align="stretch". container-type: inline-size forbids the element from being sized by its own contents, so a default (shrink-to-fit) cell collapses it to min-content and every line wraps after one word. Valid CSS, no error, nothing for axe to say — it only shows up in a screenshot, which is how it shipped in M3's LiveStatusIndicator board. sourceRules.test.ts now derives the container-declaring components from source and fails on this, so the component M4 adds joins the rule automatically.