Changelog
Every published version of @garn/ui and what changed. Generated from the package changelog, so it always matches what shipped.
0.3.0
LatestMinor Changes
App Shell: sidebar nav parts + an adaptive layout brain.
- `AppShellNav` / `AppShellNavItem` — extract the rail-collapse boilerplate into shipped parts.
AppShellNavis the<nav>landmark group (optional section heading that collapses on the rail and names the landmark viaaria-labelledby);AppShellNavItemcomposes Button with a leadingicon(stays centered on the rail), a trailingcount(soft neutral Badge, hidden on the rail), andactive(soft fill +aria-current="page"+data-active).variant/tone/asChildpass through, so it also serves a prominent sidebar action. - Adaptive layout (default-on) — the shell measures its own width and resolves a
sizeclass (compact / medium / expanded, Tailwind-aligned 640/1024): it auto-rails the sidebar at medium and floats the sidebar + aside as overlay Sheets at compact, with no consumer media queries. Auto-rail reconciles with manual ⌘B — only user toggles persist to the cookie, so widening the viewport restores the user's intent. Newadaptive/size/defaultSize/onSizeChangeprops, plususeAppShell().sizeand adata-sizeattribute forgroup-data-[size=…]styling.
Behavior change: existing shells become adaptive by default — an uncontrolled sidebar now defers to the size class, so tablet widths auto-rail and compact widths use drawers. Pass
adaptive={false}to keep the pre-adaptive static desktop layout, oropen/defaultOpento set an explicit intent.isMobilefromuseAppShell()is now an alias ofsize === "compact".- `AppShellNav` / `AppShellNavItem` — extract the rail-collapse boilerplate into shipped parts.
Badge: add
Badge.Separator, a decorative inline divider for status·action pills.The "status · action" pattern (e.g. a plan pill —
Free plan · Upgrade) previously meant hand-rolling a divider glyph and a link inside a Badge.Badge.Separatoris now a first-class compound part, and the recipe ships as an example — all while keeping Badge non-interactive by contract.- `Badge.Separator` (also `BadgeSeparator`) — a decorative divider between inline segments.
variant="dot"(default) is a typographic middot, overridable via children;variant="line"is a slimcurrentColorhairline. Both inherit the badge's tone and recede via opacity. Kind axis →data-variant;aria-hidden+select-noneso it never enters the badge's accessible name. - `PlanBadgeExample` — the Notion-style plan pill plus a
line-variant metadata divider. The action is a composed real<a>(its own focus ring), not a new Badge prop — Badge stays a static label.
No change to Badge root's API or its existing parts.
- `Badge.Separator` (also `BadgeSeparator`) — a decorative divider between inline segments.
Button: an async-honest, focus-stable control — plus
SplitButtonin ButtonGroup.Loading buttons used to go natively
disabledmid-flight, which silently dropped focus to<body>and told assistive tech nothing. This round makes the async lifecycle first-class and focus-stable, adds the layout and compound-content asks that consumers were hand-rolling, and lands the split button as smart parts ofbutton-grouprather than a new component.- Focusable loading — while
loading, the button keeps its place in the tab order:aria-disabled+ an internal activation guard, never nativedisabled.aria-busyrides along, and the shared live region announces the work (politely while pending and on success, assertively on error). Both ARIA attributes OR with any incoming value, so a caller's ownaria-disabledsurvives. - Paced spinner — nothing appears for the first ~200ms and, once shown, stays ~300ms. Fast requests never flash a spinner; slow-ish ones never blink. The spinner is mounted from the first pending frame and merely faded, so it never restarts its rotation at the moment it becomes visible.
- `state` + `onStateChange` — the full lifecycle (
idle | loading | success | error), a superset ofloading. Resolutions swap the spinner for a check or cross that draws in (motion-safe; the finished glyph, never a half-drawn one, under reduced motion) and announce completion. WireonStateChangeto the setter drivingstateand the button asks for"idle"once the glyph has been readable — it never mutatesstateitself. Reflected asdata-state, which is omitted when idle so a wrapper (a menu trigger) can own the attribute. - `fullWidth` + `justify` — fill the container instead of hugging content, and place the leading/label/trailing run (
center | start | between) once the button is wider than its content — replacing hand-rolledclassName="w-full justify-between", which lost the optical-trim contract. - `description` — an optional de-emphasized second line (the compound-button shape) for dense choice UIs and onboarding CTAs. It joins the accessible name after the label by design: the choice a compound button offers is both lines.
- `SplitButton` + `ButtonGroupText` — a primary action attached to a menu of related ones.
SplitButtonsharesvariant/tone/size/disabledwith both halves through context and owns the seam, which is derived from the active fill (a generic border token reads as a gray scratch across a brand or danger solid). Both halves stay separate native buttons, so it's two tab stops with the menu ARIA only on the chevron half; an icon-only half derives the square rung matching the wrapper, so the two can't drift in height.ButtonGroupTextis a static segment (a count, a unit) cut from the same strip. Dev-time warnings catch a missing or duplicated menu-half name, a half whose axes desync the seam, and the unsupportedrole="toolbar"case.
Behavior changes:
- A `loading` button is no longer natively disabled or dimmed. It stays focusable and keeps its resting fill (
disabled:opacity-50no longer applies). Activation is still blocked, and a plaindisabledbutton is unchanged. Code reading thedisabledattribute to detect pending should readdata-loadingoraria-disabledinstead. - Controls no longer move on press. The pre-existing
active:translate-y-pxis gone from Button, along withactive:scale-90/95on the interactive avatar and avatar-group item. Press is carried by color alone — each fill'sactive:darken plus the sunk bevel on the solids — and hover/focus affordances pick up the rest. A control that shifts under the cursor drags on every click and tears the seam in an attached cluster. - `ButtonGroup` no longer stretches in a flex parent. The base is
w-fit: as a flex item aninline-flexbox is blockified, soalign-items: stretchhad been sizing a vertical group's buttons edge to edge.w-fullinclassNamestill wins through twMerge. Its inline-axis utilities are also logical now, so an RTL group merges from the correct end. - `button` and `button-group` registry items now pull their hooks.
use-announce+use-isomorphic-layout-effectanduse-merged-refwere missing fromregistryDependencies, so a copy-in fetched source that wouldn't compile.
Metadata is updated for both components — the new props, slots, and reflected attributes, 18 machine-checkable a11y contracts, and 10 new examples.
- Focusable loading — while
Calendar becomes a smart, self-constraining month-grid substrate. It was a thin restyle of react-day-picker v10; now it owns the smarts the whole date family inherits (DatePicker / DateRangePicker / DateTimePicker), still on the v10 engine.
- First-class constraints —
minDate/maxDate/isDateUnavailablebuild the single disabled matcher and clamp navigation (startMonth/endMonth). The three pickers pass intent through instead of each rebuilding an identical matcher; typed entry keeps its own guard from the same inputs, so they can't drift. - Fast navigation — month + year dropdowns by default (
captionLayout="dropdown", year range clamped to min/max orfromYear/toYear), plus an opt-ingridNavigationdrill-down (caption → year grid → month grid). - Range feel — a preview band from the anchor to the pointed day on hover and keyboard focus (
data-preview),resetOnSelecton by default, andminNights/maxNights. - garn identity — a
sizeaxis (sm / md / lg) driving the new--garn-calendar-celltoken, its owncva, a typedCalendarProps(rdp mode/selected union intact — no bare alias), anddata-slot/data-size/data-caption-layoutreflection on the parts. - Day styling — picked day is a
brand-solidfill with a softbrand-strong/60hairline (endpoints only); today is brand text on a neutral fill that stays identifiable even when selected or inside a range. - `getDayIndicators` — up to four tone-coloured dots per day (planned / info / success / warning / danger / neutral).
- Opt-in `showToday` footer (Today jump-and-select + an aria-live selection summary), natural week rows by default (opt-in
fixedWeeksfor constant height), and opt-in reduced-motion-guardedanimate. - The nav renders in the flow (rdp
navLayout="around") so the arrows sit inside the panel padding and the calendar stays content-width.
New token:
--garn-calendar-cell(sm / md / lg) in@garn/tokens.Note: because the pickers embed Calendar, they now show month/year dropdown captions by default; pass
captionLayout="label"for a plain label.- First-class constraints —
Chart owns series animation — still under reduced motion. Recharts' entrance animation is JS-driven, so CSS
motion-reducecan't stop it;ChartContainernow can:- New
animateprop (defaulttrue), gated byusePrefersReducedMotion()— reduced motion always wins, so the reduced-motion audience gets a still chart with zero consumer wiring. Setanimate={false}to force a static chart (screenshots, print, visual-regression suites — no more waiting out the ~1.5s entrance). - The container defaults
isAnimationActiveonto the animatable recharts children (Area/Bar/Line/Pie/Radar/RadialBar/Scatter/Funnel/Tooltip) by type identity, recursing through fragments; a series' own explicitisAnimationActivealways wins. - The effective flag is exposed as
useChart().animate(for custom series and function-as-child charts, which the defaulting pass can't see) and reflected asdata-animateon the root. - Registry:
chartnow depends onuse-prefers-reduced-motion.
- New
Add the date & time picker family —
DatePicker,DateRangePicker,TimeField,TimePicker, andDateTimePicker: typed-and-pickable controls on the shared field surface, format-driven via date-fns with nativeDatevalues. Also extracts auseControllableStatehook and migratesCalendarto react-day-picker v10.Field-surface Round 2 — one shared surface for the whole form-field family.
- New
lib/field-surface.tsx: the<FieldSurface>wrapper host +fieldSurfaceVariants
(composed from
FIELD_SURFACE_BASE+fieldAppearance, so the surface colors exist in exactly one place), plusNAKED_INPUTandAFFORDANCE_BTNexported once. Registered as a registry lib item (field-surface).- Input's wrapped path, DatePicker, DateRangePicker, DateTimePicker, and TimePicker all render
<FieldSurface>; the privateinputRootVariantsanddateFieldVariantscopies are deleted.dateFieldVariantsis no longer exported fromdate-picker(usefieldSurfaceVariantsfrom the lib).- Combobox's trigger now consumes the shared
fieldVariantsdirectly;comboboxFieldVariantsis
deleted (no longer exported). Visual change: the trigger adopts the shared state values — outline hover border is
foreground/25(was/40), soft fill isforeground/5 → /[0.08]on hover (was/8 → /12), ghost hover/5(was/8) — so Combobox renders byte-identically to Input/Select at the same props. The open-state ring still mirrors the focus recipe. The popover content (role=dialog) now carries an accessible name.- InputOTP slots drop the legacy skin (
border-input,shadow-sm,ring-ring/ring-offset) for
the shared surface colors:
border-foreground/15at rest, active slotborder-foreground/40+ring-foreground/15, and — new —aria-invalidon the input turns slot edges and the active ring danger.- New
Add the FileUpload component — a drag-and-drop intake surface for business documents (résumés, attachments, deal-room docs), accessible-first and transport-agnostic.
- Accessible-first, drag-second: the dropzone is a real
<label>over a focusable (neverdisplay:none)<input type="file">, so click / Tab / Enter / Space open the picker with zero JS; drag-and-drop is progressive enhancement, anddata-statereflects idle / dragging / reject / disabled. - Transport-agnostic: it owns the file queue, validation, and per-item status/progress display — never the network request. Controlled (
files+onFilesChange) for real upload progress, or uncontrolled for a plain collect-files-for-a-form field. - Typed rejections & limits:
accept/maxSize/maxFiles/maxTotalSize/ duplicate detection, eachonFilesRejectedcarrying a per-fileerrors[](type | size | count | totalSize | duplicate) — validated on both the picker and drag paths. - Composed rows: each file renders a
List.Item(mime-type icon · name · size · inlineProgress· Retry/Remove), inheriting List's a11y and its Sortable drag-reorder; plus folder drop (directory), a running-totalSummary, and polite/assertive live-region announcements.
Zero runtime deps beyond
class-variance-authority+lucide-react; all styling is token-driven, no raw values.- Accessible-first, drag-second: the dropzone is a real
Add the Gallery component — a masonry / justified / uniform thumbnail grid for large sets of visual records (DAM assets, product shots, moodboard pins). Not a
<div>of cards: a real layout engine, a view-agnostic selection surface, and a zero-CLS media pipeline on one stable anatomy.- Three-mode layout on one `layout` variant axis:
uniform(aspect-cropped auto-fill grid),masonry(column-packed variable heights — reaches for the nativegrid-template-rows: masonryon Safari 26+ behind an@supportsgate, with a DOM-order-preserving computed-span fallback everywhere else), andjustified(Flickr / Google-Photos row-fill). Data-driven (items+ optionalrenderItem) so packing math runs from each tile's aspect ratio up front — no measure-render-remeasure thrash. - Google-Photos-grade selection: a view-agnostic
Set<id>(click · ⇧-range · ⌘/Ctrl-toggle · marquee drag · clear-on-empty), controlled or uncontrolled, plus a full roving-tabindex keyboard model (geometry-aware arrows, Home/End, Space, ⇧+arrow extend, ⌘/Ctrl+A, Esc, Enter). Selectable galleries fork torole=listbox+option+aria-multiselectable(the honest rowless multi-select pattern), read-only stayrole=list, activatable-only becomerole=groupof buttons. - Zero-CLS lazy media:
Gallery.Mediareserves each tile's aspect box, defers load until near the viewport (IntersectionObserver + nativeloading=lazy), blurs up an LQIP / skeletons while pending, and falls back to a broken-image glyph on error. - Batteries included: a built-in
lightboxcomposingDialog+Carousel(or the exported controlledGallery.Lightbox); FLIP re-layout motion on reorder/filter (animate, reduced-motion-gated); drag-reorder via thesortableprimitive (reorderable— pointer + keyboard + touch); and windowedvirtualizedrendering for large libraries. AGallery.Overlayslot hosts hover/selected tile metadata.
Zero runtime deps beyond
lucide-react; all styling is token-driven (8--garn-gallery-*tokens), no raw values.- Three-mode layout on one `layout` variant axis:
Add
InlineEdit— edit a value in place. A text-styled Preview swaps to a real field on click / focus / dblclick and back, with quiet (Enter/blur) or explicit (Save/Cancel) commit on one API. Includesvalidate+requiredgating, dirty-check (no-op commits skipped), optimistic updates with rollback, async save (stay-open on failure), a swappable typed control viauseInlineEdit(NumberField/Select), multiline autosize,maxLength, formname, a composed accessible name, andghost | outline | softappearances on the shared field surface.Add the List / ListItem content-list component — a semantic list of peer item rows (leading media · title/description · trailing meta/actions) for settings, inbox/notification feeds, file lists, search results, and resource lists.
- Dual-mode `List.Item` (terse
leading/title/description/trailing/actionsor composed slots) with an ambient-default root (appearance/size/density/selectionMode), mirroring the Timeline idiom. - Four a11y-correct interaction models on one anatomy: inert (
role=list), navigational (the card-with-primary-action overlay — whole row is one link,List.Actionsescape it), a keyboard GridList forselectionMode="multiple"/keyboardNavigation(role=gridwith roving focus, type-ahead, Space/Shift+Arrow range, ⌘/Ctrl+A, Esc, plus select-all, range-select, and a live-region bulk-action bar), and arole=radiogroupforselectionMode="single". - Surfaces & density:
appearance(plain / divided + inset / card), asizeladder including a condensedxs, and a localdensityoverride. - Depth: grouped sections (
List.Group), expandable disclosure rows (List.ItemDetail), per-rowstatus(loading/saving/error) +unread+tone, truncation, aList.Skeletonloading preset, and anList.Emptyslot. Composes cleanly withContextMenuandInlineEdit.
Drag-reorder and virtualization stay recipes (dnd-kit / TanStack Virtual), never dependencies.
- Dual-mode `List.Item` (terse
Add
MultiSelect— a multi-value / tag input on the shared field surface: an editable field holding values as removable chips with a searchable, stay-open listbox. Data-drivenoptionsor compoundMultiSelect.Itemchildren, per-option chip tone, and persistent checkmarks. Opt intoallowCustomValuefor a free-text tags mode (a "Create" row, comma/paste tokenizing viasplitChars, case-insensitive dedupe) with an optional sync/asyncvalidatethat marks bad tokens as danger chips. Adds amaxcap, avisibleLimit"+N" overflow reveal, match highlighting, an arrow-navigable roving chip strip (Backspace/Delete removal), and multi-value form submit. ComposesTag/TagGroup, the sharedFieldSurface,Popover, and cmdk.Also:
PopoverContentnow forwardsforceMountto both the Portal and Content (additive), so a consumer can keep content mounted while closed.Add
NumberField— a typed numeric control on the shared field surface. It reusesInputand adds a trailing +/− stepper plus number value/format/parse/step logic: atype="text"input withrole="spinbutton"(never nativetype="number"), formatted viaIntl.NumberFormat(formatOptions: decimal · currency · percent · unit), with a rawnumbervalue, clamp/snap on blur, float-safe keyboard stepping (↑/↓, PageUp/Down, Home/End), min/max bounds, and a hidden input that submits the raw value. Extracts an isolatednumber-parseformat/parse helper.Add the Rating component — a star/score control that is both an input and a display.
- One anatomy, two modes: interactively a visually-hidden radiogroup of real radios (native Arrow/Home/End keys, focus, and form submission via
name+required); read-only it becomes arole="img"that announces a fractional average (e.g. "4.3 of 5") with an exactly-clipped partial symbol. - Signature behaviors: cumulative fill, mouse-only hover-preview that reverts on leave, half-star
precision, and click-to-clear (clearable, default on). - Axes & content: 7-tone color intent (amber
warningdefault),sm|md|lgsizing from control tokens, swappableicon/emptyIcon, anymax, andonValueChange+onHoverChange. - Accessible by contract: warns in dev when an interactive rating is unnamed; the focus ring frames the whole symbol.
Zero runtime deps beyond
class-variance-authority+lucide-react; fill geometry is token-driven CSS, no raw values.- One anatomy, two modes: interactively a visually-hidden radiogroup of real radios (native Arrow/Home/End keys, focus, and form submission via
TimePicker's slot list is now fully keyboard-navigable. The
role="listbox"of time slots gained the APG roving-tabindex model — previously the slots were pointer-select only, so keyboard users were stuck on the typed field.- Keyboard model. Exactly one option is tabbable at a time (roving tabindex). ArrowUp/Down move the roving focus (clamped at the ends, no wrap); Home/End jump to the first/last slot; PageUp/Down move ≈an hour of slots; Enter/Space commit the focused slot, closing the popover and returning focus to the field. On open, focus lands on the selected slot (or the first when there's no value) and scrolls into view — the listbox equivalent of the calendar auto-focusing its day.
- Robustness. The focus-on-open fires only on the open transition (an external
valuechange while the list is open no longer yanks roving focus back), and the roving index is clamped when a smallerintervalor newformatshrinks the slot set, so exactly one option always stays tabbable. - Metadata caught up too (folds in the earlier metadata-only note):
time-picker.info.ts— and the generated docs / registry / llms surfaces — now describe the in-list keyboard model (a11y.keyboardrows, the rovinga11y.arialine,editorial.guidelines), and the obsolete "keyboard users can't arrow through the slot list" gotcha was replaced with the 12-hour duplicate-label one (aformatwithout a meridiem token renders AM/PM slots identically — adda).
TimeFieldis unchanged — it has no slot list (a typed field with Arrow/Page stepping already).Finish the semantic token rename (audit S2): the shadcn-era
primary/destructivenames give way to thebrand/dangervocabulary already used by thetoneaxis.New tokens + utilities (values unchanged — pixel-identical):
| Old | New | | ------------------------------------------------------- | --------------------------------------------------------------- | |
--garn-primary/bg-primary|--garn-brand-solid/bg-brand-solid| |--garn-primary-foreground/text-primary-foreground|--garn-brand-solid-foreground/text-brand-solid-foreground| |--garn-primary-strong|--garn-brand-strong| |--garn-primary-subtle(-foreground)|--garn-brand-subtle(-foreground)| |text-primary/border-primary(accent) |text-brand/border-brand| |--garn-destructive/bg-destructive|--garn-danger-solid/bg-danger-solid| |--garn-destructive-foreground|--garn-danger-solid-foreground|The old tokens and Tailwind utilities remain as deprecated aliases for this release and will be removed in the next minor. The theme builder (
/theme) now emits the new names; all components, examples, and metadata reference the new vocabulary.Add the Toolbar component — a semantic, roving-focus strip that puts a whole row of related controls on ONE tab stop and navigates between them with the arrow keys, with measured overflow that folds out-of-space actions into a menu instead of dropping them. It fills a real roster gap:
ButtonGrouponly visually joins a cluster (role=group, no keyboard model), while Toolbar adds therole=toolbarsemantics, the single tab stop with continuous arrow navigation across nested groups, and responsive collapse. It's deliberately thin — a roving + overflow shell that composes garn's Button, Toggle-group, Separator, Dropdown-menu, Popover, and Tooltip rather than re-skinning them.- APG-correct roving focus: one tab stop via roving tabindex (real DOM focus, not
aria-activedescendant); ArrowRight/Left (or Up/Down when vertical) rove between controls, Home/End jump to the ends, and arrow direction followsdirunder RTL. Therole=toolbar+ roving only switch on at 3+ controls (the APG threshold) — a 1–2 control cluster stays a plainrole=group. NestedToolbarGroup/ToolbarToggleGroupadd no second tab stop; their items traverse inline within the single arrow sequence. - Measured overflow collapse:
overflow=collapse(default) measures the bar against an off-screen mirror layer and moves the out-of-space controls into a…more-menu (a real Dropdown-menu, each control re-rendering itself as a menu row) — no action is ever lost, the bar keeps a stable single-row height, and dangling/adjacent separators are trimmed across the cut. Measurement runs synchronously in the layout effect (works in backgrounded tabs), and content changes re-fit via geometry, not a ResizeObserver.scroll/wrap/visibleare the pure-CSS alternatives. - A full set of roving-aware parts:
ToolbarButton(composes Button;tooltipfor icon-only names,shortcut→<kbd>in the tooltip + a hint in the menu +aria-keyshortcuts),ToolbarToggleGroup/ToolbarToggleItem(single/multiple selection that delegates roving to the bar and survives relocation into the menu via a root-lifted store),ToolbarLink, andToolbarInput— a first-class text/search member that is pinned in the bar (never collapses), takes over Arrow/Home/End for the caret while focused, and is placed last per APG. - `ToolbarSplitButton` — a primary action attached to a menu of related ones as ONE roving stop, the toolbar-native fix for the fact that a standalone
SplitButtonisn't a roving item (its halves keeptabIndex 0, add stray tab stops, and count nothing toward the APG gate). The menu half istabIndex=-1, reached on the perpendicular arrow axis with the menu ARIA on it alone, counts once toward the threshold, and collapses to a primary row plus a submenu named bymenuLabel. - `ToolbarFloating` — the selection / bubble toolbar (Notion / Tiptap / Medium style). It composes Popover to anchor a
variant=floatingbar against an element or a virtualanchor(a ref exposinggetBoundingClientRect), keeps focus on the selection by default (autoFocusoff), and strips the popover chrome so the inner bar owns the elevated look. - Structural + surface axes:
variant=docked | floating(structural — never color, per CLAUDE.md §5);appearance=solid | soft | outline | ghost;size=sm | md | lg(density → gap / padding / radius, control heights from--garn-control-*, never hand-padded);orientation=horizontal | vertical. Asoftbar setsdata-elevatedso its selected toggles lift to a card "thumb" that reads on the tint (WCAG 1.4.11) instead of a same-luminance fill swap. Every enum axis reflects to itsdata-*attribute. - Focusable-when-disabled: a disabled ToolbarButton / ToolbarToggleItem / ToolbarLink / ToolbarSplitButton keeps
aria-disabled+ a click guard rather than nativedisabled(which drops it from roving), so it stays arrow-reachable in the sequence but inert to activation.
Ships with full metadata: props, slots, and reflected attributes; 22 machine-checkable a11y contracts (single tab stop, roving arrows, vertical orientation, Home/End, RTL, nested-group single sequence, disabled-focusable, icon-name, overflow reachability + reduced-motion, pinned input, and the split-button / floating contracts) including the 4 dev-time accessible-name warnings mirrored verbatim; and 10 examples (formatting bar, appearances, sizes, vertical rail, overflow collapse, app action bar, split button, vertical overflow, pinned search, floating selection bubble).
Adds one dependency,
@radix-ui/react-toolbar(^1.1.12), for the roving-focus engine.- APG-correct roving focus: one tab stop via roving tabindex (real DOM focus, not
Add the
Treecomponent — an arbitrary-depth WAI-ARIA tree view: expand/collapse with roving-tabindex keyboard, single/multiple selection, tri-state checkbox cascade, filter-to-reveal with highlighting, scroll-to-node, async lazy children, and inline rename.Enforce the variant doctrine —
tone= color intent,appearance= fill,variant= kind/structural (Button's emphasis axis is the named exception). Axis names are now reflected to matchingdata-*attributes and linted in metadata.BREAKING — axis renames (no aliases):
- Alert:
variant→tone; the neutral valuedefault→neutral.<Alert variant="danger">→<Alert tone="danger">,<Alert variant="default">→<Alert tone="neutral">(or omit). Reflectsdata-tone(wasdata-variant). - Toggle / ToggleGroup:
variant→appearance(values unchanged:default | outline).<Toggle variant="outline">→<Toggle appearance="outline">. Reflectsdata-appearance. - Kbd:
variant→appearance(values unchanged:default | ghost).<Kbd variant="ghost">→<Kbd appearance="ghost">. Reflectsdata-appearance.
BREAKING — Button splits into orthogonal `variant` (fill) × `tone` (color):
variantnow carries only fill/emphasis (default | soft | outline | ghost | link); the new optionaltone(neutral | brand | danger | success | warning, defaultneutral) carries color intent and reflects todata-tone. Value mapping:| before | after | | ------------------------------------------------- | ----------------------------------------------------------- | |
variant="brand"|tone="brand"| |variant="danger"|tone="danger"| |variant="danger-outline"|variant="outline" tone="danger"| |variant="danger-ghost"|variant="ghost" tone="danger"| |variant="success"|tone="success"| |variant="warning"|tone="warning"| |variant="secondary"|variant="soft"(it was an indistinguishable neutral soft) | |default/soft/outline/ghost/link| unchanged (tone defaults toneutral) |The axis is now complete: every tone combines with
default/soft/outline/ghost(e.g.success-outline,warning-ghostnow exist).linkis tone-inert.secondaryis removed — it resolved to the same neutral surface token assoft(tone=neutral), sovariant="soft"replaces it.Minor behavior change:
variant="soft"now renders a neutral soft surface (it previously implied brand). For the old brand-tinted soft, usevariant="soft" tone="brand".- Alert:
Patch Changes
App Shell: capture the master–detail / three-pane pattern as a recipe.
The selection→peek wiring, empty-detail state, and mobile stacking of a master–detail view (email, CRM record, settings, file browser) were re-derived on every app. They're now a documented
master-detailrecipe on App Shell plus a copy-paste example.- Recipe (`master-detail`) — a single-select
List(orTable) in Main drives the detailAppShellAside: selecting a row peeks the aside open (controlledasideOpen) and swaps its contents, anEmptyfills the aside until a row is chosen, and at the compact tier the aside floats as an overlay Sheet — no consumer media queries. Matchable viagarn_match_recipe("master detail", "three-pane", "split view", "list selects → detail → collapses on mobile") with an enforceable a11y contract. - Example (`master-detail`) — a runnable three-pane demo wiring
List selectionMode="single"→onSelectionChange→asideOpen, with theEmptyempty-detail branch and theAppShellAsideTrigger.sizeis pinned toexpandedso the three-pane is deterministic in the lab. - New gotcha + `slotAccepts` edges to
list(master pane) andempty(empty-detail state), so the wiring is discoverable instead of re-derived.
No runtime API change — App Shell already exposes the slots and the controlled
asideOpen/adaptivesizethis composes.- Recipe (`master-detail`) — a single-select
Combobox now highlights the matched substring in option labels. The run of an option's label matching the current search is wrapped in a
<mark data-slot="combobox-item-match">(case-insensitive first-hit on plain-string labels), so the list shows why each option matched — the same treatmentMultiSelectalready ships, lifted verbatim so the two cmdk siblings read identically. No-op for non-string children or an empty search, and no API/ARIA change (the<mark>preserves the option's accessible name).Command: give
CommandDialogan accessible name. It now renders a default sr-onlyDialogTitle/DialogDescription("Command menu" / "Search for a command to run."), satisfying Radix's required accessible name and removing the missing-name console error. Both are overridable via the newtitle/descriptionprops.Drawer now moves focus into the surface on open, matching the Radix modal family (Dialog/Sheet) and the APG modal-dialog contract. vaul ships its open-autofocus off (to keep focus for its drag gesture), which left keyboard and screen-reader users parked on the trigger, outside the modal. garn's
Drawerroot now defaultsautoFocuson, so focus lands on the first focusable inside the content on open (verified in a real browser across Chromium/Firefox/WebKit). A consumer with a drag-first sheet or an input that shouldn't grab focus can opt out withautoFocus={false}.Filters (FilterBar) metadata — the Phase-2 catch-up for the component shipped in `feat(filters): … Phase 1` (`cadcb71`). Authors
filters.info.tsfrom scratch so the roster, docs (/components/filters), registry, cards, and llms surfaces describe the component. No API or behavior change.- Roster + surfaces —
filtersnow appears in the CLAUDE.md forms roster and generatesout/md/filters.md,out/cards/filters.md, the registry item, and llms.txt entries;versions.jsonfreezes it at0.2.0. - A11y contract — documents the shipped keyboard/ARIA model: the chip row is a
role="grid"TagGroup (interactive remove buttons ⇒ grid, not listbox) with roving tabindex, Enter-to-drill / arrow-rove between segments / Esc-exit / Backspace-remove-and-refocus-neighbour, descriptive segment names (Operator: … Change,Remove filter: …), and arole="status"result count. Four contracts (filters-chips-grid-semantics,filters-remove-focus-to-neighbour,filters-segment-accessible-names,filters-result-count-status). - Anatomy — 17
data-slotregions (bar · chips · chip + operator/value/remove segments · value-editor · conjunction · nested group · add · result-count · clear · saved-views · ask · empty/loading/error) and the 10 exportedFilterBar.*parts; the chip's reflecteddata-type/data-operator/data-tone/data-state(complete·pending) are described. - Props —
FilterBar's 8 root props (fields/filters/defaultFilters/onFiltersChange/records/engine/apiRef/size) surface via theFilterBaranatomy part (the id-derivedFiltersPropsname doesn't exist — the main interface isFilterBarProps), plusFilterBar.Ask/FilterBar.Errorown props. - Usage + AI hints — whenToUse / whenNotToUse (names the alternatives: the faceted-filter recipe, Input search, MultiSelect, Command), 5 recipes (data-table filter bar, faceted value editor, natural-language Ask, saved views, nested groups), relationships (composes tag/popover/command/input/number-field/date-picker/switch/spinner/multi-select; pairsWith table), and 7 gotchas (the
[&_svg]:size-4!important chip-icon trap,fieldsvsenginerequired, pending chips don't filter, rolling-vs-calendar relative dates, controlled-without-onChange, records-drive-the-count, "Ask returns a query not an answer"). - 8 examples registered (basic · prefilled · live-results · full-matrix · nested-groups · ask · saved-views · url-round-trip).
- Roster + surfaces —
TimePicker slots are now keyed by time-of-day, not by label. Under a 12-hour
formatwithout a meridiem token (e.g."hh:mm"), the AM and PM slots render with the same label (01:00for both 1 AM and 1 PM), so keying the list by label collided (key={s.label}→ duplicate React keys). The slots are keyed by theirhour:minuteinstead, which is always unique.
0.2.0
Minor Changes
Badge now renders its
countsugar with the real Counter (replacing the placeholder chip). The trailing count gains Counter's behavior — a99+overflow cap (socount={128}now reads99+, with the true count announced to assistive tech), the odometer digit roll, andtabular-numswidth stability. Tone and size follow the badge; the count's appearance inverts against the badge's (a solid badge gets a soft count chip and vice versa) so it always contrasts. Thedata-slot="badge-count"anchor is unchanged. Badge now listscounteras a registry dependency.Add BadgeSelect — a single-value "pick to change" control styled as a Badge (the editable-Lozenge / status-picker pattern). The trigger reads as a tinted lozenge until you interact with it, then opens a single-select list to change the value; its signature behavior is that the trigger's tone follows the selected option (choose "Done" → green, "Blocked" → red), so a table/board cell encodes state in both label and colour. It composes garn's `Select` (Radix Select): single-select state (controlled + uncontrolled), open/close, focus, popper positioning, typeahead, the full keyboard model, and form integration (the hidden native
<select>vianame) are all inherited. Listbox (not menu) semantics — the collapsed trigger must convey a value, which a menu button can't. The trigger is resolved by walking the option children synchronously at render, so it paints the right tone on first paint (before the portal mounts).Styling reuses the badge family's tone ×
appearance×shape×sizevocabulary and the shared--garn-badge-*tokens (mode-aware): seven tones (neutral/brand/info/success/warning/danger/discovery), anappearanceofsoft(a light tinted lozenge, default),outline, orframed(nosolid— a filled trigger reads as a button/CTA), ashapeofsharp/rounded/pill, and a badge-sizedsize(sm=20px /md=28px, fixed across density). The trigger always follows the selected option's tone; aplaceholdershows when nothing is selected.Options are compound
BadgeSelectItemchildren — each withvalue,tone(mirrored into the trigger when selected, and the row's leading dot),leading(icon / avatar / badge, mirrored into the trigger),trailing(row-only secondary meta), andcount(a tone-matchedCountertally, shown in the row and mirrored into the trigger as a solid contrast-popping pill) — or a data-drivenoptionsarray.BadgeSelectGroup/BadgeSelectLabel/BadgeSelectSeparatorsection long lists. Aloadingprop puts aSpinnerin the trigger and disables it for pending async changes.Accessibility: the trigger is a
comboboxwith alistboxofoptions (single-select,aria-selected+ a trailing check); it requires an accessible name (aria-label/aria-labelledby, or a<label htmlFor>matching the triggerid) — a dev-time warning fires when one is missing, and the placeholder doesn't count. Ships with metadata, a 14-example docs page, and a behavior/ARIA test suite. Listsselect,counter, andspinneras registry dependencies.Add Counter — a standalone numeric count pill (the productized
BadgeCount). It caps overflow at99+(max), hides at zero (showZero), can abbreviate large counts (format="compact"→1.2K), and rolls its digits on change with a dependency-free CSS odometer that degrades to an instant swap underprefers-reduced-motion. The visual value isaria-hiddenwhile the true count rides ansr-onlyaria-live="polite"region, so assistive tech always hears the real number and changes are announced without moving focus. Tone ×appearance× size ×shapecome from the matrix and resolve through the shared--garn-badge-*tokens (mode-aware): seven tones (neutral/brand/info/success/warning/danger/discovery— discovery is a new violet/purple badge-token family), anappearanceofsolid(filled bubble, default) orsoft(light tinted fill + darker tinted text), and ashapeofsharp(rectangle),rounded, orpill(default). An optionalpulseadds a reduced-motion-safe "new activity" halo.Also adds `Counter.Indicator` — a wrapper that anchors a marker onto a child element (a notification bell, an avatar): a bare presence dot or a composed Counter at the corner. Logical, RTL-safe positioning (
position9-cell grid,overlaprectangular/circular for round targets,offset), a surface-colored ring (withBorder), hide-at-zero /invisible, andpulse. It composes Counter for the count case, so the cap/roll/announce all come for free; the bare dot is decorative (aria-hidden) and the target carries the meaning in its label.Shared field surface.
Input, theSelecttrigger, andTextareanow render one identical control surface (border ·--garn-control-*height/padding · hover · focus-visible ring ·aria-invalid· disabled) from a single exportedfieldVariantscva (appearance × size), ending the drift where each control re-declared its own near-duplicate styling. The surface lives in a new copy-in lib,field-variants(a registry dependency of input/select/textarea), exportingfieldVariants,fieldAppearance, andFIELD_SURFACE_BASE.- BREAKING — `Input`'s `variant` prop is renamed to `appearance` (no alias):
<Input variant="soft" />→<Input appearance="soft" />. Values are unchanged (outline | soft | ghost), and the reflected attribute is nowdata-appearance(wasdata-variant). The internalinputVariantsexport was removed in favour of the sharedfieldVariants. - `Select` and `Textarea` gain the `appearance` axis (
outline | soft | ghost) they previously lacked, and their size scale is unified to xs–xl (wassm | md | lg). Both also drop the legacyshadow-sm/border-input/ring-offsetlook in favour of the shared flat surface, and reflectdata-appearance. - Fix — `SelectItem` highlight on hover. Options used
focus-visible:for the highlight, which only matched keyboard focus, so hovering options showed no highlight; switched tofocus:(matching the rest of the Radix-menu family) so pointer-hover and keyboard both highlight.
- BREAKING — `Input`'s `variant` prop is renamed to `appearance` (no alias):
Modernize every component to the React 19 shape — ref-as-prop: drop
forwardRef, manualdisplayName, and theReact.ElementRef/ComponentPropsWithoutReftyping in favour of plain functions typed withReact.ComponentProps. Components now **reflect their cva axes ontodata-*** (data-variant,data-size,data-tone,data-side, …) alongside the existingdata-slot, so consumers can style variants viagroup-data-[…]without prop drilling."use client"added tobutton. No public prop or behaviour changes.Add Stepper — a compound, accessibility-first wizard for ordered, often-gated processes (checkout, onboarding, multi-section forms). The root owns the active-step index (controlled
value/ uncontrolleddefaultValue/onValueChange, via an in-houseuseControllableState) and runs an async forward-gate; siblings read it from context.Anatomy:
Stepper·StepperList(nav > ol) ·Step(<li>, carriestitle/description/icon/completedIcon/optional/error/disabled/loading/subStep) ·StepIndicator·StepSeparator·StepTitle/StepDescription·StepContent(position-matched panel) ·StepperCompleted·StepperPrevTrigger/StepperNextTrigger(asChild). Public cva:stepperIndicatorVariants.Axes:
orientation(horizontal / vertical) ×variant(numbered / dot — indicator shape) ×look(filled / Carbonoutline— a bundled visual language, numbered-only; dots are look-agnostic) ×size(sm / md / lg) ×labelPlacement(end / bottom, horizontal-only) ×linear(gated forward via Next, completed steps revisitable) vs non-linear (any step clickable). Step state (upcoming / current / complete / error / disabled) is derived from the active index and surfaced asdata-state;data-orientation/data-variant/data-look/data-sizereflect onto the root, everyStep, andStepIndicatorforgroup-data-[…]styling. Namedlook(notappearance) to keep it distinct from Badge/Tag/Counter's orthogonaltone × appearancefill vocabulary — there is notoneaxis because a stepper's colours are state-derived, not user-chosen.Accessibility (the differentiator — APG has no stepper pattern): a real
nav > ol > liwitharia-current="step"on the active step,aria-hiddenindicators, and a singlerole="status"polite live region announcing "Step N of M: <title>".StepContentis arole="region"labelled by its step title, hidden when inactive; an interactive steparia-controlsits panel only when one exists (no dangling reference). Thetablistmodel is deliberately avoided (it implies free navigation that conflicts with linear gating).onBeforeStepChange({ from, to, direction })gates forward moves (Back is always free), disables Next while pending, and shows a spinner on the target indicator; the progress connector animates viascaleX(--stepper-progress)and collapses underprefers-reduced-motion. Semantic tokens only; React-19 ref-as-prop throughout. Ships a 27-test suite (structure, a11y contract, data-\* reflection, state derivation, linear/non-linear navigation, controlled/uncontrolled + Prev/Next, async gate, content panels).Add Tag / TagGroup — interactive, removable + selectable chips, modelled on React Aria's TagGroup. A standalone
<Tag>is a lone filter token: dismissable viaonRemove(a real, named "Remove {label}" button) or clickable / a link viaasChild. A<TagGroup>coordinates a set of Tags withselectionMode(none|single|multiple, controlledselectedKeys/ uncontrolleddefaultSelectedKeys+onSelectionChange,disabledKeys,disallowEmptySelection) and per-chip removal — all keyed off each Tag'svalue. A selected chip reads as a leading check + a stronger solid fill.Built in-house on the grid interaction pattern: the group is one tab stop (
role="grid",aria-multiselectablein multiple mode), arrows + Home/End rove between chips (eachrole="row", logical so they mirror under RTL), Enter/Space toggle selection, and Backspace/Delete removes the focused chip and moves focus to its neighbour — arowis not an interactive role, which is what lets a chip host a focusable remove button without a nested-interactive violation. A dev-time probe warns when aTagGrouphas no accessible name (labelauto-wiresaria-labelledby);emptyrenders a slot when all chips are removed.Styling reuses Badge's vocabulary verbatim —
tone(seven:neutral/brand/info/success/warning/danger/discovery) ×appearance(solid/soft/outline/framed) ×shape(sharp/rounded/pill) ×size(sm= 20px /md= 28px, with a flat 6px padding-x), resolved through the shared--garn-badge-*tokens (mode-aware in the token layer) — so the badge family (Badge / Counter / Tag) reads as one system. State changes (hover, selection, focus ring, disabled) animate via a reduced-motion-safe transition. Ambient tone/appearance/shape/size flow from the group via context; any Tag may override.A chip can also carry a `count` (composes
<Counter>— tone/size matched, appearance contrast-inverted) and a `trailing` slot, rendered before the remove button. Compound partsTag.Leading/Tag.Label/Tag.Removeare exported as the escape hatch. Long labels cantruncate(+maxWidth).
Patch Changes
BadgeSelect: remove the
tintedprop (and itstinted={false}"tinted off" mode). The trigger now always follows the selected option's tone — the component's signature behavior is non-optional. A fixed-tone trigger defeated that behavior and was broken in practice.Removing a public prop is a breaking change by nature; it lands as a
patchonly because BadgeSelect has not shipped a release yet (itsAdd BadgeSelectchangeset is still pending), so there is no published API to break. To pin a fixed trigger tone, set a single-tone option set instead.Accessibility fixes surfaced by the new Phase 4 test suite:
- Progress — forward
valueto the RadixProgress.Root(not only the indicator transform) so it wiresaria-valuenow/data-state. The bar previously filled visually but screen readers announced no value. - Slider — forward
aria-label/aria-labelledbyto eachSlider.Thumb(therole="slider"element). They were landing on the Root wrapper, so the slider handle shipped with no accessible name (affecting every documented example).
- Progress — forward
0.1.0
Minor Changes
Initial 0.1.0 release — the 48-component garn surface, documented end-to-end via ComponentInfo (schema → registry/markdown/Fumadocs/llms.txt/MCP) with lifecycle (version/since/deprecation) wired through every channel.
Versioned with Changesets. Per-component history lives on each component page under its Changelog tab.