Changelog
Lastest significant updates and changes to the Significa Foundations library.
May 2026
Fixed: Tooltip stacks above an open Popover
A Tooltip rendered inside a Popover (or any popover="manual" container) used to render behind the popover instead of above it. Root cause was in useTopLayer: the hook ran in useLayoutEffect, but <FloatingPortal> doesn’t have its target element in the DOM on the first render — the portal node is created in an effect, the element mounts on the second render, and by then the layout effect had already run with ref.current === null. Result: showPopover() was never called for the tooltip, so the tooltip wasn’t in the top layer, and the popover (which was) painted above it.
Two fixes:
useTopLayernow returns a callback ref instead of a ref object, so the popover state is set whenever the element attaches — regardless of how many render passes the consumer’s wrappers take. If you also need imperative access to the element, compose this ref with your ownuseRefviacomposeRefs.Tooltip.Contentopts intopopover="hint". Per the Popover API, hints stack above other popovers without dismissing them — the right semantic for a tooltip.
- const ref = useTopLayer<HTMLDivElement>(isOpen);
+ const ref = useTopLayer<HTMLDivElement>(isOpen, 'hint'); // for tooltip-style elements
// If you previously read `ref.current` inside the hook's caller:
- const ref = useTopLayer<HTMLDivElement>(true);
- useEffect(() => { ref.current?.togglePopover(); }, []);
- return <div ref={ref}>…</div>;
+ const elementRef = useRef<HTMLDivElement>(null);
+ const topLayerRef = useTopLayer<HTMLDivElement>(true);
+ useEffect(() => { elementRef.current?.togglePopover(); }, []);
+ return <div ref={composeRefs(elementRef, topLayerRef)}>…</div>;
Changed: Semantic typography tokens
We introduce four semantic typography tokens covering the roles a brand actually pairs:
--font-ui— UI chrome (buttons, inputs, menus, badges, tooltips, modal/drawer titles); the render default, applied via inheritance frombody.--font-body— body text (prose, paragraphs); opted into in strategic places like markdown.--font-heading— h1–h6.--font-mono— code, kbd, tabular numerics.
--font-ui and --font-heading both default to var(--font-body), so a brand shipping one face sets --font-body once and the rest cascades through. The editorial pattern (display heading + serif body + sans chrome) sets all three.
The mechanism, without retrofitting every primitive: components pick up --font-ui automatically via inheritance, and prose/headings opt UP into their own tokens. When a brand differentiates them, the chrome font (the “standard” one) ends up most prevalent — exactly what you want when pairing a serif body with a sans for chrome.
Consumers using the old font-sans Tailwind utility need to swap to font-body.
@theme {
- --font-sans: ui-sans-serif, system-ui, sans-serif;
+ --font-body: ui-sans-serif, system-ui, sans-serif;
+ --font-ui: var(--font-body);
+ --font-heading: var(--font-body);
--font-mono: ui-monospace, monospace;
}
body {
- font-family: var(--font-sans);
+ font-family: var(--font-ui);
}
Added: isLoading on search inputs
Popover.SearchInput now accepts an isLoading prop. When true, the leading magnifying-glass icon is replaced with a spinner. Listbox.SearchInput and Menu.SearchInput forward the prop, so the same flag works everywhere a search field is rendered. Useful for async-filtered lists where the user types and the options are fetched.
<Listbox.SearchInput
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
+ isLoading={isFetching}
/>
Fixed: Popover mobile behavior
Two changes that affect every primitive built on Popover (Tooltip, Listbox, Select, DatePicker, anything with a floating panel).
-
Virtual keyboards no longer flip the popover and dismiss focused inputs.
Popovernow pausesautoUpdatewhile a text-input descendant of the floating panel has focus. iOS firesscrollandresizeevents onwindow.visualViewportwhen the soft keyboard opens; those used to re-runflip()against the shrunken viewport, the panel re-rendered with a new placement, and any focused input inside it lost focus and dismissed the keyboard in a loop. The pause is implemented insidewhileElementsMounted(so listeners are tied to floating-ui’s lifecycle, no extrauseEffect):focusinon the panel sets a flag,focusoutclears it, and theupdate()callback short-circuits while it’s set. -
New
flipFallbackPlacementsoption onusePopoverFloating. Forwards directly toflip()’sfallbackPlacements. Useful when an upstream component (a nested submenu, a side-anchored popover) needs cross-axis fallbacks on narrow viewports — e.g.['left-start', 'bottom-start', 'top-start']lets aright-startpanel fall back below the trigger when neither side fits. Default behavior unchanged when the option is omitted.
// popover.tsx — usePopoverFloating
+ flipFallbackPlacements?: Placement[];
- whileElementsMounted: (reference, floating, update) =>
- autoUpdate(reference, floating, update, { layoutShift: false }),
+ whileElementsMounted: (reference, floatingEl, update) => {
+ let paused = false;
+ const isTextInput = (n: EventTarget | null) =>
+ n instanceof HTMLElement &&
+ (n.tagName === 'INPUT' ||
+ n.tagName === 'TEXTAREA' ||
+ n.isContentEditable);
+ const onFocusIn = (e: FocusEvent) => {
+ if (isTextInput(e.target)) paused = true;
+ };
+ const onFocusOut = (e: FocusEvent) => {
+ if (isTextInput(e.target)) paused = false;
+ };
+ floatingEl.addEventListener('focusin', onFocusIn);
+ floatingEl.addEventListener('focusout', onFocusOut);
+
+ const cleanup = autoUpdate(
+ reference,
+ floatingEl,
+ () => {
+ if (paused) return;
+ update();
+ },
+ { layoutShift: false }
+ );
+
+ return () => {
+ cleanup();
+ floatingEl.removeEventListener('focusin', onFocusIn);
+ floatingEl.removeEventListener('focusout', onFocusOut);
+ };
+ },
- flip({ padding: 8 }),
+ flip({ padding: 8, fallbackPlacements: flipFallbackPlacements }),
Changed: Input.Group lays out flush addons; pointer-events-auto ceremony dropped
Input.Group no longer carries horizontal padding. The in-group input owns its own px, and Input.Addon sits flush at the group’s edges with size-aware padding inherited from a new InputGroupContext. Action-button addons (password toggles, country pickers, NumberInput-style steppers) now have full edge-to-edge hit areas without the -ml-* / -mr-* workarounds. Static addons (icons, text) keep their visual position.
Two consumer-affecting changes:
Input.Groupno longer auto-detectssize/variantfrom a child<Input>. Pass them on the group directly. This was already broken whenever the child input was wrapped (e.g.NumberInput.Field), so explicit-only is both simpler and more honest.Input.Addonno longer defaults topointer-events-none. Interactive addons (buttons,Tooltip.Trigger,Popover.Trigger) now work withoutclassName="pointer-events-auto". Click-to-focus on static addons (icons, text) is preserved via a target-closest heuristic — clicking a leading icon still focuses the input, while interactive descendants keep their own click semantics.
// Move size/variant to the group:
- <Input.Group>
- <Input size="sm" placeholder="…" />
- <Input.Addon>suffix</Input.Addon>
- </Input.Group>
+ <Input.Group size="sm">
+ <Input placeholder="…" />
+ <Input.Addon>suffix</Input.Addon>
+ </Input.Group>
// Drop `pointer-events-auto` on interactive addons:
- <Input.Addon className="pointer-events-auto" asChild>
- <button>+351</button>
- </Input.Addon>
+ <Input.Addon asChild>
+ <button>+351</button>
+ </Input.Addon>
Standalone <Input> is unchanged. inputStyle is unchanged, so consumers who reuse it (Textarea, OTPInput, DatePicker, Listbox, Select) need no update. Select.Group and Select.Addon (aliases of the input pair) inherit the new behavior automatically.
New: Table primitive
A styled compound primitive for <table>/<thead>/<tbody>/<tfoot>/<tr>/<th>/<td>/<caption>, plus a Table.SortableHead helper that wires aria-sort and a caret affordance. State stays with the consumer — for sorting, filtering, selection, and pagination together, the docs ship a TanStack Table recipe. Virtualization, pivot, and column resize are intentionally out of scope; reach for AG Grid or LyteNyte when you need them.
Dropdown replaced by Menu, with nested submenu support
Dropdown is gone. Menu takes its place with the same surface area for flat menus plus first-class support for nested submenus (Menu.ItemTrigger opens a submenu rendered as a child <Menu>). Hover-to-open with safePolygon, right/left-arrow nav between levels, and FloatingTree coordination so any item click closes the whole tree. Item styling now exposes a variant prop with a built-in destructive option.
For consumers, the migration is mechanical — rename and you’re done:
- import { Dropdown } from "@/foundations/ui/dropdown/dropdown";
+ import { Menu } from "@/foundations/ui/menu/menu";
- <Dropdown>
- <Dropdown.Trigger asChild>...</Dropdown.Trigger>
- <Dropdown.Items>
- <Dropdown.Section>
- <Dropdown.Heading>Actions</Dropdown.Heading>
- <Dropdown.Item onSelect={...}>Edit</Dropdown.Item>
- <Dropdown.Divider />
- <Dropdown.Item className="text-red-500">Delete</Dropdown.Item>
- </Dropdown.Section>
- </Dropdown.Items>
- </Dropdown>
+ <Menu>
+ <Menu.Trigger asChild>...</Menu.Trigger>
+ <Menu.Items>
+ <Menu.Section>
+ <Menu.Heading>Actions</Menu.Heading>
+ <Menu.Item onSelect={...}>Edit</Menu.Item>
+ <Menu.Divider />
+ <Menu.Item variant="destructive">Delete</Menu.Item>
+ </Menu.Section>
+ </Menu.Items>
+ </Menu>
If you reach into the floating context, useDropdownContext is now useMenuPopoverContext:
- import { useDropdownContext } from "@/foundations/ui/dropdown/dropdown";
+ import { useMenuPopoverContext } from "@/foundations/ui/menu/menu";
For nested submenus:
<Menu>
<Menu.Trigger asChild><Button>Open</Button></Menu.Trigger>
<Menu.Items>
<Menu.Item onSelect={...}>New file</Menu.Item>
<Menu>
<Menu.ItemTrigger>Share</Menu.ItemTrigger>
<Menu.Items>
<Menu.Item onSelect={...}>Copy link</Menu.Item>
<Menu.Item onSelect={...}>Email</Menu.Item>
</Menu.Items>
</Menu>
</Menu.Items>
</Menu>
Popover learned origin for cursor-anchored panels
Popover (and therefore Menu) now accepts origin: "trigger" | "pointer" | [number, number]. "pointer" captures the click coordinates on the trigger and anchors the floating panel there instead of next to the trigger element. Passing an explicit [clientX, clientY] tuple lets you build right-click context menus without a separate primitive.
// Open at click position
<Popover origin="pointer">...</Popover>
// Right-click context menu
const [pos, setPos] = useState<[number, number] | null>(null);
<div onContextMenu={(e) => { e.preventDefault(); setPos([e.clientX, e.clientY]); }}>
...
</div>
<Menu open={!!pos} onOpenChange={(o) => !o && setPos(null)} origin={pos ?? 'trigger'}>
<Menu.Items>...</Menu.Items>
</Menu>
New: useKeyboardShortcut hook
A small hook for global keyboard shortcuts. mod: true is the cross-platform modifier — ⌘ on macOS, Ctrl elsewhere — which is what you almost always want for app-level commands like ⌘K.
useKeyboardShortcut({ key: "k", mod: true }, () => setOpen(true));
Fixed: Dropdown.Divider and Listbox.Divider vertical spacing not tracking --inset
Dropdown.Item and Listbox.Option already breathe inward with --inset (side margins, plus mt/mb on first/last). The dividers were hard-coded to my-1, so at --radius: 0 items sat flush to the panel edge but a constant 8px gap remained around dividers. The divider’s vertical spacing should derive from the same dial as the rest of the inset behaviour.
Fix: switch the dividers’ vertical margin to my-(--inset).
const DropdownDivider = ({ className, ...props }) => {
- return <Divider className={cn('my-1', className)} {...props} />;
+ return <Divider className={cn('my-(--inset)', className)} {...props} />;
};
const ListboxDivider = ({ className, ...props }) => {
- return <Divider className={cn('my-1', className)} {...props} />;
+ return <Divider className={cn('my-(--inset)', className)} {...props} />;
};
Fixed: Popover scrolling the page on open
Floating UI returns x / y as null on the first render before positioning runs. The panel was therefore rendered at top: 0, left: 0, and FloatingFocusManager’s autofocus then made the browser scroll-into-view toward the document’s top-left before floating-ui applied the real coordinates. Visible as a small upward jump every time the trigger was clicked while the page was scrolled. Affects every Popover consumer (Dropdown, DatePicker, DSConfig, etc.).
Fix: hide the panel via visibility: hidden until floating-ui reports isPositioned. The panel still mounts (so it can be measured), but autofocus targets nothing focusable until the real position lands.
const PopoverContent = (...) => {
- const { context, refs, getFloatingProps, modal } = usePopoverContext();
+ const { context, refs, getFloatingProps, modal, isPositioned } =
+ usePopoverContext();
return (
<PopoverPanel
context={context}
modal={modal}
+ isPositioned={isPositioned}
...
/>
);
};
- const PopoverPanel = ({ ref, context, modal, ... }) => {
+ const PopoverPanel = ({ ref, context, modal, isPositioned = true, ... }) => {
// ...
+ const hidden = !isPositioned || context.middlewareData.hide?.referenceHidden;
return (
<FloatingFocusManager context={context} modal={modal}>
<div
style={{
// ...
- visibility: context.middlewareData.hide?.referenceHidden ? 'hidden' : 'visible',
+ visibility: hidden ? 'hidden' : 'visible',
}}
/>
</FloatingFocusManager>
);
};
Fixed: Calendar last day row flush with panel edge
When a Calendar was rendered inside a container that doesn’t provide its own bottom padding (most visibly: DatePicker without shortcut items below — Dropdown.Items overrides Popover’s p-3 with p-0), the bottom day row sat directly against the panel border. The CalendarHeader had a built-in p-1.5 that gave the top some breathing room; the day grid had no equivalent.
Fix: add pb-1.5 to the day grid container so the last row mirrors the header’s spacing.
<div
role="grid"
aria-label={...}
+ className="pb-1.5"
>
Months and years views are unaffected — their absolute overlay already provides symmetric padding via p-1 on the wrapper.
Added: Toggle and ToggleGroup
A two-state button primitive — and a group wrapper for related sets. Toggle reuses Button’s sizing tokens (size, square, asChild) and adds pressed / defaultPressed / onPressedChange. There’s no variant prop: every toggle renders as a ghost-style affordance with a subtle filled tint when on. If you need a bordered frame, pass className="border border-border" at the call site.
ToggleGroup handles the toolbar pattern: roving tabindex, arrow-key navigation, and type="single" | "multiple" selection.
<Toggle aria-label="Pin">
<PushPinIcon />
</Toggle>
<ToggleGroup type="multiple">
<ToggleGroup.Item value="bold" aria-label="Bold" square>
<TextBIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="italic" aria-label="Italic" square>
<TextItalicIcon />
</ToggleGroup.Item>
</ToggleGroup>
Changed: Button now styles data-pressed as pressed
The base buttonStyle now reacts to data-pressed with a subtle filled tint (bg-foreground/10). This is what makes Toggle and ToggleGroup.Item look “on” without a separate stylesheet — and means consumers can opt any Button into the same pressed look by setting the attribute. data-pressed follows the library’s convention of boolean state attributes (omitted when false), so a single selector covers both standalone and grouped toggles.
base: [
// ...
+ 'data-pressed:bg-foreground/10 data-pressed:hover:bg-foreground/15',
],
If you’ve never set a pressed-style attribute on a Button, this is invisible to you.
Changed: buttonStyle group merge is now orientation-aware
The in-data-ui-button-group: corner/border-merge selectors used to assume a horizontal row — they always stripped the right-side border/corner from non-last items and the left-side from non-first items. With ToggleGroup shipping a vertical orientation, that logic produced the wrong visual (right borders dropped on a vertical stack instead of bottom borders).
The selectors now branch on an ancestor data-orientation="vertical" attribute. ButtonGroup doesn’t set the attribute, so it gets the horizontal rules by default — no behavior change. ToggleGroup already sets data-orientation={orientation} on its container, so vertical groups now merge along the correct axis.
base: [
// ...
- 'in-data-ui-button-group:not-last:rounded-r-none in-data-ui-button-group:not-last:border-r-0',
- 'in-data-ui-button-group:not-first:rounded-l-none in-data-ui-button-group:not-first:border-l-0',
+ // horizontal merge (default)
+ 'in-data-ui-button-group:not-in-data-[orientation=vertical]:not-last:rounded-r-none',
+ 'in-data-ui-button-group:not-in-data-[orientation=vertical]:not-last:border-r-0',
+ 'in-data-ui-button-group:not-in-data-[orientation=vertical]:not-first:rounded-l-none',
+ 'in-data-ui-button-group:not-in-data-[orientation=vertical]:not-first:border-l-0',
+ // vertical merge
+ 'in-data-ui-button-group:in-data-[orientation=vertical]:not-last:rounded-b-none',
+ 'in-data-ui-button-group:in-data-[orientation=vertical]:not-last:border-b-0',
+ 'in-data-ui-button-group:in-data-[orientation=vertical]:not-first:rounded-t-none',
+ 'in-data-ui-button-group:in-data-[orientation=vertical]:not-first:border-t-0',
],
If you only use horizontal ButtonGroup / ToggleGroup, this is invisible. If you use vertical ToggleGroup, copy the new selectors into your buttonStyle.
Fixed: Tooltip breaking sibling-selector layouts
Tooltip.Content rendered as a DOM sibling of its trigger because <Tooltip> itself is a context provider with no DOM wrapper. Inside a Button.Group / ToggleGroup, the rendered tooltip <div> would then become the new :last-child of the group — flipping :not(:last-child)-based styles on the previously-last button (the right border and right-rounded corners disappeared while the tooltip was open).
Fix: portal Tooltip.Content to <body> via <FloatingPortal> (from @floating-ui/react). The DOM position changes; the visual top-layer stacking via useTopLayer is unaffected (the Popover API works regardless of DOM position).
+ import { FloatingPortal } from '@floating-ui/react';
if (!isMounted) return null;
return (
+ <FloatingPortal>
<div ref={ref} ...>
...
</div>
+ </FloatingPortal>
);
If you targeted the tooltip element via .trigger + [popover] or similar sibling selectors (rare), update those — the tooltip is no longer a sibling of its trigger. Aria wiring (aria-describedby) and floating-ui’s positioning are unaffected.
Fixed: SegmentedControl first-paint tab stop
Items register via useLayoutEffect, so on SSR (and the first client render before the effect flushes) the internal segments array was empty. The selected item’s tabIndex={isSelected ? 0 : -1} therefore evaluated to -1 for every item until hydration completed — the group had no tab stop. Fix: derive the rendered selection by walking children directly when segments is empty, falling back to the explicit value once items register.
- const selectedSegment = selectedValueProp ?? internalSelectedValue;
+ const explicitSelected = selectedValueProp ?? internalSelectedValue;
+ const selectedSegment = useMemo(() => {
+ if (explicitSelected !== undefined) return explicitSelected;
+ if (segments.length > 0) return segments[0];
+ // Pre-registration: first child's `value`.
+ let first: string | undefined;
+ Children.forEach(children, (child) => {
+ if (first !== undefined) return;
+ if (isValidElement<{ value?: unknown }>(child) && typeof child.props.value === 'string') {
+ first = child.props.value;
+ }
+ });
+ return first;
+ }, [explicitSelected, segments, children]);
Added: Spinner variants (dots, bars, frames)
Spinner now takes a variant prop. The default is still ring (no behavior change for existing consumers — including Button’s loading state). Three new variants ship alongside it: dots (three pulsing dots), bars (three-bar equalizer), and frames (cycles through any character array, with a SPINNER_FRAMES preset export and a frames/interval prop pair for custom sets).
If you want the new dots or bars variants, copy the keyframes into your @theme block — they animate via Tailwind’s --animate-* token convention:
@theme {
/* ... */
+ --animate-spinner-dot: spinner-dot 1.4s ease-in-out infinite both;
+ --animate-spinner-bar: spinner-bar 1.2s ease-in-out infinite both;
+
+ @keyframes spinner-dot {
+ 0%, 80%, 100% { opacity: 0.25; }
+ 40% { opacity: 1; }
+ }
+
+ @keyframes spinner-bar {
+ 0%, 40%, 100% { transform: scaleY(0.4); }
+ 20% { transform: scaleY(1); }
+ }
}
The frames variant is JS-only (no CSS setup needed). All variants animate regardless of prefers-reduced-motion — loading indicators communicate state, so freezing them would break the signal (consistent with the existing ring variant). Pass any readonly string[]:
<Spinner variant="frames" />
<Spinner variant="frames" frames={SPINNER_FRAMES.moon} interval={120} />
<Spinner variant="frames" frames={["—", "\\", "|", "/"]} />
April 2026
Fixed: Tabs indicator animating between pages
If you are using the Tabs in your project, you should apply this fix.
The selected-tab indicator uses motion’s layoutId to animate between tabs. The layoutId was set to useId(), which produces the same value (e.g., :r0:) for the first Tabs in any React tree. When navigating between pages that each contain a Tabs component, motion treated the two indicators as a shared layout and animated between them.
Fix: combine useId() with a module-level counter so each Tabs mount has a globally-unique scope.
+ // Module-level counter combined with useId() to give each Tabs instance a
+ // globally-unique layout scope.
+ let tabsInstanceCounter = 0;
const Tabs = ({ ... }) => {
- const id = useId();
+ const reactId = useId();
+ const [id] = useState(() => `${reactId}-${++tabsInstanceCounter}`);
// ...
};
Fixed: Tabs panels all rendering on first paint
If you are using Tabs in your project, you should apply this fix.
Each TabsItem registers itself in a tabs array via useLayoutEffect. On the initial render (SSR + hydration), the array is empty, so tabs[index] is undefined for every panel — and the matching check selectedTab === id becomes undefined === undefined, which is true for every panel. All panels rendered until hydration corrected things.
Fix: wrap each panel (and each item) in an index context, and fall back to index-based matching when ids haven’t been registered yet.
+ const PanelIndexContext = createContext<number>(0);
const TabsPanels = ({ children, ... }) => {
const { tabs } = useTabsContext();
return (
<div ...>
{Children.map(children, (child, index) => (
+ <PanelIndexContext value={index}>
<PanelIdContext value={tabs[index]}>{child}</PanelIdContext>
+ </PanelIndexContext>
))}
</div>
);
};
const TabsPanel = (...) => {
const id = use(PanelIdContext);
+ const index = use(PanelIndexContext);
+ const { selectedTab, selectedIndex } = useTabsContext();
- const { selectedTab } = useTabsContext();
- const isSelected = selectedTab === id;
+ const isSelected =
+ id !== undefined ? selectedTab === id : index === selectedIndex;
// ...
};
The same pattern is applied to TabsItems / TabsItem so the active indicator renders on the right tab from the first paint instead of popping in after hydration. selectedIndex was added to the Tabs context for both fallbacks. See the full diff in the source for the matching trigger-side change.
Fixed: Slider thumb position with non-zero min
If you are using the Slider in your project, you should apply this fix.
The progress factor was computed as value / max, ignoring min. Sliders with a non-zero min placed the thumb at the wrong fraction of the range. The bug was in two places — the useMemo for the React render path and the direct-DOM update inside Slider.Thumb’s onChange. Both need fixing.
// Slider component
- const progressFactor = useMemo(() => value / max, [value, max]);
+ const progressFactor = useMemo(
+ () => (value - min) / (max - min),
+ [value, min, max]
+ );
// SliderThumb's handleChange
if (sliderElement) {
sliderElement.style.setProperty(
'--progress-track-factor',
- `${newValue / max}`
+ `${(newValue - min) / (max - min)}`
);
}
Added: <Field> component (and removal of standalone <Label>)
Foundations now ships a Field component that wires labels, descriptions and errors to form controls without forking every primitive. Wrap your input in <Field.Control> and the right id / aria-labelledby / aria-describedby / aria-invalid attributes get injected via Slot. Multiple descriptions and conditional errors compose into one aria-describedby automatically.
<Field invalid={!!errors.email}>
<Field.Label>Email</Field.Label>
<Field.Control>
<Input type="email" {...register("email")} />
</Field.Control>
<Field.Description>We'll never share it.</Field.Description>
<Field.Error>{errors.email?.message}</Field.Error>
</Field>
The base form primitives (Input, Textarea, Checkbox, Radio, Select, Switch) stay dumb — they don’t know about Field. When you want accessible wiring, you opt in by wrapping. When you don’t, native form controls work as-is.
This replaces the standalone <Label> component, which was a thin wrapper over <label> with default styling. Field.Label ports the same styling, so consumer copies have a clean migration path:
- import { Label } from '@/foundations/ui/label/label';
- <Label htmlFor={id}>Email</Label>
- <Input id={id} {...} />
+ import { Field } from '@/foundations/ui/field/field';
+ <Field>
+ <Field.Label>Email</Field.Label>
+ <Field.Control><Input {...} /></Field.Control>
+ </Field>
If you used <Label> outside any form context (rare), a native <label className="font-medium text-base text-foreground"> is the drop-in replacement.
This also replaces the previous accessible-forms guide, which required forking every form primitive to consume a useField() hook. The educational content (ARIA reasoning, custom-select caveat, RadioGroup nesting pattern) has been ported to the new component’s docs page.
Added: semantic color tokens
Added --color-error, --color-warning, --color-success and --color-info to the theme. Components that previously hardcoded red-*, emerald-*, yellow-* and blue-* (Badge, Toaster, Button’s destructive variant, Input’s invalid state) now use these tokens. Updating a semantic color is a one-line change instead of a find-and-replace across files.
- 'bg-red-500/10 text-red-600 ring-red-600/20'
+ 'bg-error/10 text-error ring-error/20'
Added: configurable border radius
The --radius custom property is now the dial that controls the system’s roundness. Named radius tokens are derived from it:
--radius: 0.125rem;
--radius-xs: calc(var(--radius) * 1);
--radius-sm: calc(var(--radius) * 2);
--radius-md: calc(var(--radius) * 3);
--radius-lg: calc(var(--radius) * 4);
--radius-xl: calc(var(--radius) * 6);
--radius-2xl: calc(var(--radius) * 8);
--radius-3xl: calc(var(--radius) * 12);
--radius-4xl: calc(var(--radius) * 16);
Default values match Tailwind’s defaults exactly, so existing components render identically. Change --radius and the entire system scales proportionally.
Added: --inset token for panel-item breathing room
A new --inset: calc(var(--radius) * 2) token controls the breathing room between panel items and their container edges. Square systems (--radius: 0) collapse to flush items; rounded systems get pill insets that follow the outer curve.
Dropdown items and Listbox options use it via mx-(--inset) and stay curve-correct at any radius.
Added: configurable focus ring width
Added --ring-width (default 4px). All primitives now use ring-(length:--ring-width) instead of hardcoded ring-4, so the focus halo can be tuned in one place.
- 'focus-visible:ring-4 ring-ring'
+ 'focus-visible:ring-(length:--ring-width) ring-ring'
Changed: Slider visual polish
- Track now spans the full slider width to align visually with surrounding content. This means the thumb doesn’t exactly track the track progress but the drift should be minimal and non-consequential.
- Thumb styled with
bg-background, border andshadow-xsto match Input and Dropdown.
Added: squircle corners (progressive enhancement)
Added a --corner-shape token (default squircle) and applied corner-shape: var(--corner-shape) globally. Where the browser supports the corner-shape property (currently Chrome), all rounded elements render as iOS-style superellipses. Other browsers ignore the declaration and fall back to the standard circular border-radius. .rounded-full is opted out so circles stay circles.
A squircle looks visually less rounded than a circle at the same border-radius, so derived radius tokens (--radius-md, --radius-lg, …, --inset) are scaled by --radius-bump, which is 1 by default and 1.6 under @supports (corner-shape: squircle). The user-facing --radius dial keeps meaning “design intent” — the bump is applied internally so a given value yields comparable perceived roundness across browsers.
To opt out of squircles entirely, set --corner-shape: round in your :root.
Changed: Calendar accessibility (WAI-ARIA grid pattern)
The Calendar now follows the WAI-ARIA grid pattern:
- The day grid (and the months/years overview grids) get
role="grid"with a context-awarearia-label(e.g.,"March 2026","Months in 2026","Years 2021 to 2032"). - Each row is wrapped in
role="row"(usingdisplay: contentsfor the months/years case so the CSS grid layout is preserved). - Weekday header cells get
role="columnheader"with the full day name asaria-label(the visible character is still the narrow form, but screen readers announce “Monday” instead of “M”). - Each date button gets
role="gridcell",aria-selected, and a natural-languagearia-labelviatoLocaleDateString({ dateStyle: 'full' })— “Friday, March 14, 2026” — instead of the previous machine-readableyyyy-MM-ddstring. - Header navigation buttons now use context-aware labels:
"Previous month"/"Next month"in days view,"Previous year"/"Next year"in months view,"Previous 12 years"/"Next 12 years"in years view (the buttons used to always say “Previous month” / “Next month”, which was misleading in the other views).
If you’ve copied Calendar into your project and want these improvements, the diff is mostly additive: add the role/aria attributes and switch the label format.
Added: Tabs underline variant
Tabs now accepts a variant prop — pill (default, current behavior) or underline. The underline variant uses a thin accent-colored line under the active trigger instead of the filled-pill indicator. The active-tab motion.span with layoutId is shared between variants, so the indicator still animates between triggers either way.
<Tabs variant="underline">
<Tabs.Items>
<Tabs.Item>One</Tabs.Item>
<Tabs.Item>Two</Tabs.Item>
</Tabs.Items>
<Tabs.Panels>
<Tabs.Panel>...</Tabs.Panel>
<Tabs.Panel>...</Tabs.Panel>
</Tabs.Panels>
</Tabs>
Default is pill, so existing usages are unaffected.
Added: reduced motion support in Disclosure, Tabs and Toaster
These three components use motion (Framer Motion) for animations. Motion does not respect prefers-reduced-motion by default; it has to be opted in. Each of the three now wraps its motion content in a <MotionConfig reducedMotion="user"> so animations are automatically shortened when the user requests reduced motion.
- import { motion } from 'motion/react';
+ import { motion, MotionConfig } from 'motion/react';
// inside the component's render
+ <MotionConfig reducedMotion="user">
{/* ... motion children ... */}
+ </MotionConfig>
MotionConfig only shortens animation durations — it doesn’t suppress transform values like scale or y. For Toaster the exit animation { opacity: 0, scale: 0.8, y: '-100%' } was snapping instantly to the scaled/translated state on dismiss, which looked jarring. Toaster now uses useReducedMotion() to drop the transform values entirely when reduced motion is active, leaving just an opacity change.
+ import { useReducedMotion } from 'motion/react';
const ToasterItem = (...) => {
+ const reduceMotion = useReducedMotion();
return (
<motion.div
...
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.8, y: '-100%' }}
+ initial={reduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.95 }}
+ animate={reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
+ exit={
+ reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.8, y: '-100%' }
+ }
/>
);
};
Changed: Disclosure data-open → data-state
Disclosure’s trigger and content elements now use data-state="open" | "closed" instead of data-open={true | false} to match the convention used by the rest of the library (Popover, Listbox, Tooltip).
- data-open={open}
+ data-state={open ? 'open' : 'closed'}
If you target the disclosure trigger with data-[open=true]:... selectors in your project, update them to data-[state=open]:....
Added: Avatar.Group
Avatar now ships an Avatar.Group sub-component for stacked avatars. Each non-leading avatar is cut by an SVG mask shaped after the previous avatar’s variant and size, so the gap is transparent rather than a painted ring. Direct <Avatar> children only — others are filtered out.
<Avatar.Group>
<Avatar>
<Avatar.Image src="..." />
<Avatar.Fallback>JD</Avatar.Fallback>
</Avatar>
<Avatar>
<Avatar.Image src="..." />
<Avatar.Fallback>AS</Avatar.Fallback>
</Avatar>
</Avatar.Group>
March 2026
Changed: compound components
All foundational components have been refactored to use a compound component pattern. Related sub-components are now exposed as static properties of their parent component rather than as separate named exports.
This reduces import noise and makes the relationship between components explicit at the call site.
Migration Example
Definition
- export { Badge, BadgeStatus, BadgeIcon };
+ const CompoundBadge = Object.assign(Badge, {
+ Status: BadgeStatus,
+ Icon: BadgeIcon,
+ });
+
+ export { CompoundBadge as Badge };
Usage
- import { Badge, BadgeStatus, BadgeIcon } from "@/components/ui/badge";
+ import { Badge } from "@/components/ui/badge";
const Card = ({ title, status }) => {
return (
<div>
<Badge>
- <BadgeIcon>
+ <Badge.Icon>
<Icon name="star" />
- </BadgeIcon>
+ </Badge.Icon>
- <BadgeStatus>{status}</BadgeStatus>
+ <Badge.Status>{status}</Badge.Status>
</Badge>
<h3>{title}</h3>
</div>
);
};
Previous
About
Next
Instance Counter