Skip to main content

Changelog

Release notes for @ngrok/mantle, generated from the package's CHANGELOG.md via changesets. The same content is available as plain markdown at /changelog.md for agent and tooling consumption.

See also the npm version history and the GitHub releases.


@ngrok/mantle

0.93.3

Patch Changes

  • #1643 943c2ff Thanks @cody-dot-js! - Add a head prop to TruncatedText, fit its tooltip to the value, and drop its help cursor.

    • head is the most graphemes that show before the ellipsis. A value longer than head + tail graphemes always clips to that shape, and the root shrinks to fit. For an ngrok resource ID, head={id.indexOf("_") + 1} keeps the type prefix: mem_…A8KImPbQ. The component measures the lead, so a caller needs no ch math and no monospace font.
    • The tooltip is up to 24rem wide, so a resource ID fits on one line. A longer value no longer leaves one or two characters alone on the last line: the lines have near-equal length, and the tooltip narrows to the longest line.
    • A truncated value no longer shows the help cursor on hover. The cursor is the default text cursor, like a value that fits.

    See https://mantle.ngrok.com/components/data-display/truncated-text.

0.93.2

Patch Changes

  • #1641 08e9ff1 Thanks @cody-dot-js! - Add ThemeSwitcherIcon and useThemeToggle to @ngrok/mantle/theme-switcher, and let ThemeDropdownMenuItem take children. You can now put the theme toggle icon where you want it in a menu row, such as first, to line up with the icons of other rows.

    • ThemeDropdownMenuItem takes children in place of its default row. Put a ThemeSwitcherIcon where the icon goes. With no children, the item renders the same row as before.
    • ThemeSwitcherIcon is the ThemeSwitcher icon on its own: a sun or a moon for the applied theme, in a <span data-slot="theme-switcher-icon">. The new theme grows in a circle from it.
    • useThemeToggle returns disabled, label, and onSelect for a DropdownMenu.Item that you compose yourself. A press flips the theme and keeps the menu open.
    <ThemeDropdownMenuItem>	<ThemeSwitcherIcon />	Theme</ThemeDropdownMenuItem>

    See https://mantle.ngrok.com/components/forms/theme-switcher#laying-out-the-row.

0.93.1

Patch Changes

  • #1636 122e171 Thanks @cody-dot-js! - Tabs.Trigger: a mouse press no longer shows the keyboard focus ring. Before, a click after a Tab key press showed the ring on the clicked tab in Chromium, because Radix focuses the trigger from script and Chromium gives that focus the ring state of the element that had focus before. Keyboard focus still shows the ring.

    The label text is not selectable (select-none), so a selection across the page copies no tab labels.

    An asChild link trigger now activates on click, not on the press. Before, a press released off the link switched the tab but followed no link, so the panel and the URL disagreed. The link also no longer starts a drag on a small mouse move, which cancelled the click.

  • #1639 dce0a88 Thanks @cody-dot-js! - ThemeProvider and useInitialHtmlThemeProps no longer throw in a sandboxed iframe that blocks cookies. In an iframe with sandbox="allow-scripts" and no allow-same-origin, a read of document.cookie throws a SecurityError. In such a frame, mantle now reads the theme from ssrCookie, so the client agrees with the server HTML. With no ssrCookie, it renders the default theme.

0.93.0

Minor Changes

  • #1622 c05af74 Thanks @cody-dot-js! - Add Query and @ngrok/mantle/query-filter, the filter language it speaks.

    Query is a single-line filter field where free text and key:value qualifiers interleave, such as is:shared -staging api. The field colors each token in place. A suggestion popover completes keys and values while the caret stays in the field. The field is a real <input role="combobox">, so the caret, selection, IME composition, paste, and undo stay native.

    import { type FilterGrammar, filterItemsByQuery } from "@ngrok/mantle/query-filter";import { Query } from "@ngrok/mantle/query"; <Query.Root	grammar={projectFilterGrammar}	items={projects}	value={query}	onValueChange={setQuery}>	<Query.Input		aria-label="Filter projects"		placeholder="Filter projects, or type a filter like is:shared"	/>	<Query.Content>		<Query.List>{(suggestion) => <Query.Item suggestion={suggestion} />}</Query.List>		<Query.Empty />		<Query.Footer>			<Query.Legend />			<Query.KeyboardHints />		</Query.Footer>	</Query.Content></Query.Root>;
    • Query.Root owns the caret, the open state, and the active suggestion, and lays out its children in a row. It is controlled only: pass value and onValueChange. Pass items for live match counts, or leave it out when a server runs the query.
    • Query.Input is the field, with a leading magnifier and a clear button that shows while the query has text. It takes the <input> props, such as disabled, required, readOnly, and validation, as Input does. Its children render inside the field as inline actions, before the clear button. clearLabel names the clear button, and onClear runs after a clear. Inside Field.Control, it takes its id, name, and ARIA wiring from the Field.Item. Keys, values, and OR use the CodeBlock colors. Set --query-key-color, --query-value-color, or --query-operator-color to change them.
    • Query.Content is the suggestion popover. You compose its parts, and you leave out a part to remove it. It accepts the Popover.Content props, such as side and id, except the ones it owns.
    • Query.List is the listbox, grouped under "Filter by", "Exclude", and "Values". Its child is a function that gets each suggestion and returns a Query.Item.
    • Query.Item is one suggestion row. It shows the label, the description, and the match count, or the children you pass. Call preventDefault() in its onClick to skip the insert.
    • Query.Empty shows when no suggestion matches, and the popover stays open. By default, it says what the field accepts at the caret.
    • Query.Footer stays under the rows while they scroll, and it joins the input's description while the popover is open. It takes asChild.
    • Query.Legend shows the syntax the grammar honors. Query.KeyboardHints shows the keys the field answers to, and enterHint names what Enter does.
    • Data attributes: data-slot is query, query-input, query-input-actions, query-content, query-list, query-item, query-empty, query-footer, query-legend, and query-keyboard-hints. data-highlighted marks the active row, or the row under the pointer.

    @ngrok/mantle/query-filter has no React import, so a server or a script can parse and match with the same rules. It exports the FilterGrammar contract, defineRemoteFilterGrammar, the total parser parseFilterQuery, toggleFacet to add or remove one qualifier, the matchers filterItemsByQuery and countItemsByQuery, getFilterSuggestions, and getHighlightSegments. For a natural-language search, describeFilterGrammar describes the language as JSON for a model, and checkFilterQuery finds the unknown keys and values in the query that the model returns, and the bare terms that a "conjunction" grammar cannot run. These two helpers are in preview, so their API can change.

    The engine is faster than the copy it replaces in ngrok's apps. It compiles a query once per pass, reads each item's search text by index, and counts the value suggestions over the items the rest of the query keeps. Median times on 50,000 items:

    CaseBeforeAfter
    Filter by one term2.9 ms1.3 ms
    Filter by qualifiers, an exclusion, and a term4.7 ms1.8 ms
    Filter with OR4.9 ms2.6 ms
    Filter by three exclusions5.4 ms2.6 ms
    Count a new query3.1 ms1.3 ms
    Value suggestions, the rest narrows12.8 ms2.1 ms
    Value suggestions, the rest keeps all15.4 ms4.7 ms
    Value suggestions after a refetch12.4 ms3.3 ms

    For each items array, the caches cost up to one pointer and 4 bytes per item, and at most 1,000 counts.

    Docs: https://mantle.ngrok.com/components/forms/query and https://mantle.ngrok.com/utils/query-filter

Patch Changes

  • #1630 061c62a Thanks @joelhans! - dotenv joins the supported code block languages. mantleCode("dotenv"), Markdown fences, and the server highlighter render .env files with Shiki's dotenv grammar, which ships in the existing shiki dependency. Before this change, a dotenv fence was not highlighted, and the rehype plugin left it as a plain <pre> without CodeBlock styles.

  • #1622 c05af74 Thanks @cody-dot-js! - useDebouncedCallback accepts a callback with typed parameters, such as (query: string) => void. Before, TypeScript rejected every callback that did not take unknown arguments, including the one in the hook's own example.

  • #1627 4970366 Thanks @cody-dot-js! - Fix the indentation of tab-indented code in CodeBlock on Firefox. A tab now shows as two spaces in every browser. The code block wrote -moz-tab-size: 2px, which Firefox reads over tab-size, so each tab was 2px wide. CodeBlock.Code now ignores a MozTabSize in its style prop for the same reason.

  • #1631 b6cd8a6 Thanks @cody-dot-js! - Bump @tanstack/react-virtual from 3.14.22 to 3.14.23.

  • #1629 c9c50aa Thanks @cody-dot-js! - Show Sandbar above an open Dialog, AlertDialog, or Sheet and its scrim. Before, the overlay covered the bar. A click on the bar, or in a menu or popup opened from the bar, now does not dismiss the overlay, the same as a toast. A menu or popup opened from the bar shows above the bar.

  • #1634 977abd8 Thanks @cody-dot-js! - Keep the Slider thumb white in dark mode for better contrast.

0.92.1

Patch Changes

  • #1620 694c2dd Thanks @cody-dot-js! - TruncatedText clips its head and keeps its tail again. In 0.92.0, Tailwind compiled the head and tail max-width rules to invalid CSS, and the browser dropped them. A long value overflowed its box, so a sibling such as the IdTag copy button painted over the text.

0.92.0

Minor Changes

  • #1619 914629c Thanks @cody-dot-js! - mantle.css holds the dark theme too, so an app loads one stylesheet.

    Each page makes one fewer render-blocking stylesheet request. The CSS is also smaller over the wire, because the two themes compress as one file. With every mantle class in the build, minified, the CSS goes from 41.0 KB to 38.4 KB with gzip (−6.6%), and from 29.9 KB to 27.6 KB with Brotli (−7.4%).

    Breaking:

    • @ngrok/mantle/mantle-dark.css is gone. Delete its ?url import and every use of the URL, its CSS @import, and the mantle-dark-styles <link> in a non-React <head>.
    • MantleStyleSheets, mantleStyleSheetUrls, MantleStyleSheetsProps, and MantleThemeCssUrls are gone from @ngrok/mantle/theme. Delete <MantleStyleSheets /> and its mantleStyleSheetUrls call. PreventWrongThemeFlashScript stays in <head>.

    The resolved colors do not change, with one exception. When the <html> theme class and data-applied-theme disagree, the light rule now wins. Before, the stylesheet order decided. ThemeProvider and PreventWrongThemeFlashScript keep the two in step, so this affects only a page whose writers get different forceTheme values.

    The codemod deletes the imports, the element, and the call. Migration guide: https://mantle.ngrok.com/migrations/0012-single-stylesheet-migration

Patch Changes

  • #1615 1d37d30 Thanks @cody-dot-js! - Accordion.TriggerIcon now stays centered on the first line of the trigger label when the label wraps. Before, it was centered on the full trigger height.

  • #1610 81ee570 Thanks @cody-dot-js! - $cssProperties from @ngrok/mantle/types now checks each key of an object literal. A key that starts with -- is still a valid CSS variable, and the return type is still React's CSSProperties, so style={$cssProperties({ "--gap": 8, ...style })} compiles as before. A misspelled key that does not start with --, such as displya, now fails to compile. Before, it compiled and had no effect. Fix the spelling at that call site.

  • #1610 81ee570 Thanks @cody-dot-js! - useUndoRedo spells its constraint T extends {}, the type that NonNullable<unknown> resolves to. Calls compile as before.

  • #1616 a511d5e Thanks @cody-dot-js! - isApplePlatform returns false during a server render. Before this release, it read the navigator global, which Node 21 and later also define, so a server on macOS could report an Apple platform and the client could then hydrate a different shortcut hint.

    The source now passes the stricter lint rules that the ngrok frontend repo uses. The other changes are internal and do not change behavior.

    Tailwind classes in the source use their canonical form. The default preferredWidth of Sheet.Content is now spelled sm:max-w-120, which is the same width as sm:max-w-[30rem].

  • #1612 895815b Thanks @cody-dot-js! - Every component and hook in dist now ships React Compiler output, except the virtualization hook inside List. That hook opts out on purpose with "use no memo", because the compiler cannot model @tanstack/react-virtual. Before this release, the compiler skipped useInertSiblings and the click handler of DataTable.ControlCell and gave no warning, so those two had no memoization. Both now compile. Their behavior does not change.

    The mantle build now fails when the compiler cannot compile a function, so a function that the compiler skips with no warning can no longer reach a release.

    What changes for you:

    • useInertSiblings and DataTable.ControlCell get the same memoization as the rest of mantle.
    • You do not need to change your code.
  • #1614 b868a2b Thanks @cody-dot-js! - The mantle build now runs every React Compiler validation, and lint enforces every rule that ports a compiler diagnostic. To pass them, these parts move a state update out of an effect, or make an effect read each value in its dependency list:

    • List grid navigation clamps its active row during render when the list shrinks. A shrink that clamps the row now takes 1 commit instead of 2.
    • Charts read the aria-labelledby text for the data-table caption with useSyncExternalStore. A chart mount with aria-labelledby now takes 2 commits instead of 3. When aria-labelledby names an element that contains the chart, the caption skips the text of the data table.
    • Sandbar.Root sets its presence during render when open changes. Open and close still take 2 commits each.
    • AppLayout.SidePanel resets its transition gate during render when it opens. An open still takes 2 commits.
    • Charts clear the keyboard announcement during render, and the color effect passes className and style to the engine.
    • CodeBlock.Code attaches the fold handler only to highlighted HTML, the only HTML that has fold toggles.

    What changes for you:

    • No API changes. You do not need to change your code.
    • The validations do not change compiler output. A build with them and a build without them are byte-identical.
    • The commit counts come from a React Profiler probe in the unit test environment, the same on 3 runs, against the previous source.
  • #1617 21f2f37 Thanks @cody-dot-js! - Fix three behaviors that surprised consumers, and document the others in JSDoc and on the docs site.

    • Separator, and so Tabs.Separator, no longer shrinks to zero height in a flex container that overflows, such as a scrolling Dialog.Body. A className with a shrink class still wins.
    • The Sidebar.Root ⌘B / Ctrl+B shortcut now does nothing when an earlier keydown listener called preventDefault(), as the Command.DialogRoot ⌘K shortcut already does.
    • The --shadow-* tokens are now CSS custom properties on :root, so a stylesheet that Tailwind does not process can read var(--shadow-md). See https://mantle.ngrok.com/base/shadows.

    New JSDoc notes:

    • Button: pass an icon through the icon prop, never as a child. The icon gets size-5 unless the icon sets its own size, asChild wraps a plain <a> and not Anchor, and the disabled={!isReady || isPending} pattern.
    • Combobox.Input: Enter never submits a parent form while the combobox is open, with a free-text example. Combobox.Root: control open when you render Combobox.Content conditionally. Combobox.Content: children must be items, groups, or separators.
    • Charts: a xKey, dataKey, or zKey that no row has throws. The Root is aspect-video by default, the legend scrolls past max-h-16, and a mode="decorative" chart hides its children from assistive technology.
    • ThemeProvider: the mantle-ui-theme cookie and the events that apply it again. Set the cookie and reload in browser automation.
    • Dialog.Content, Sheet.Content, and AlertDialog.Content: the overlay-prompt class lets UI such as a consent banner take clicks above an open overlay. It does not reach the keyboard or assistive technology.
    • useInView: do not combine once: true with initial: true.
    • Breakpoint: xs (480px) is wider than most phones, so give each xs: class an unprefixed base.
    • The docs site lists the reserved ⌘K and ⌘B chords at https://mantle.ngrok.com/accessibility#reserved-keyboard-shortcuts.

0.91.1

Patch Changes

  • #1609 b26cff1 Thanks @cody-dot-js! - AppLayout.Workspace fills the frame at every width again. It no longer caps at --app-layout-max-width or centers with mx-auto, so on an ultrawide monitor the rail, the card, and the side panel span the whole window and leave no empty bands at the sides. The --app-layout-max-width variable is gone and has no effect.

    See https://mantle.ngrok.com/layouts/app-layout#applayoutworkspace.

  • #1608 258e10f Thanks @cody-dot-js! - Align component motion with the mantle motion rules.

    • Popover, Tooltip, HoverCard, DropdownMenu, Select, Dialog, AlertDialog, and Sheet now enter in 200ms and exit in 150ms on the ease-out-strong curve. Under prefers-reduced-motion, they open and close without animation.
    • Dialog, AlertDialog, and Sheet play their exit animation again. Since 0.83.8, they unmounted at once on close.
    • Floating content (Popover, Tooltip, HoverCard, DropdownMenu, and Select) now slides 4px instead of 8px. It slides back on exit, so the exit mirrors the enter.
    • Sandbar now enters in 200ms instead of 400ms and exits in 150ms on ease-out-strong.
    • The Sandbar blocked-navigation shake and the MultiSelect locked-tag shake now play the form-control error shake: 4px, settling in 300ms. A shake that repeats while one runs restarts it.
    • The Switch thumb, the ProgressBar indicator, and the Alert, CodeBlock, and SplitButton carets now move in 200ms on ease-out-strong, and stay still under prefers-reduced-motion.
    • The PasswordInput visibility icon now opens from half height on ease-out-strong.
  • #1606 d9444ef Thanks @cody-dot-js! - Run one theme view transition at a time. A theme change during a reveal now ends the reveal and applies the theme without motion. Fast clicks on ThemeSwitcher no longer start a new view transition inside the click handler of a skipped one, which can crash the Chrome renderer.

0.91.0

Minor Changes

  • #1600 8ef6e80 Thanks @cody-dot-js! - Animate icon swaps. The outgoing icon fades, shrinks, and blurs out while the next icon comes into focus. A second swap during the motion reverses it from its current value. With reduced motion, the swap is instant.

    • Button and IconButton: the isLoading spinner swaps with the icon.
    • CodeBlock.CopyButton and the Chart copy buttons: the copy icon swaps with the check icon.
    • ThemeSwitcher.Trigger: the icon swaps when the applied theme changes.

    Each icon stays mounted, so the Button and IconButton icon is now inside a <span data-slot="icon-swap">, in a <span data-slot="icon-swap-item">. The item that shows has data-state="active". The hidden items have data-state="inactive" and aria-hidden="true". A [&>svg] selector on a button no longer reaches its icon; use [&_svg] instead. See the migration guide: https://mantle.ngrok.com/migrations/0010-button-icon-swap-migration

  • #1599 a913c10 Thanks @cody-dot-js! - Remove the high-contrast themes, simplify the theme script, and make ThemeSwitcher a light/dark toggle. See the migration guide; its codemod rewrites most call sites.

    • Two themes. Theme is "system" | "light" | "dark", and ResolvedTheme is "light" | "dark". The @ngrok/mantle/mantle-light-high-contrast.css and @ngrok/mantle/mantle-dark-high-contrast.css exports and the high-contrast: and dark-high-contrast: variants are gone. "system" follows prefers-color-scheme only; prefers-contrast no longer changes the theme.
    • A stored high-contrast preference degrades. A light-high-contrast cookie reads as light and a dark-high-contrast cookie reads as dark. ThemeProvider rewrites the cookie on mount.
    • Smaller theme script. PreventWrongThemeFlashScript reads the cookie and sets the theme class. It no longer writes a cookie or reads the legacy localStorage key.
    • MantleStyleSheets renders one <link>. It takes darkCssUrl only, and mantleStyleSheetUrls takes { darkCssUrl }. The lightHighContrastCssUrl, darkHighContrastCssUrl, forceTheme, ssrCookie, and nonce props and the fixMediaScriptContent export are gone.
    • ThemeSwitcher is a toggle. <ThemeSwitcher /> replaces ThemeSwitcher.Root, ThemeSwitcher.Trigger, and ThemeSwitcher.Content. A click flips between light and dark. A click back to the OS setting stores "system", so a second click always returns to the OS default. Its accessible name says which theme the click shows. ThemeSwitcherProps replaces the three part prop types.
    • ThemeDropdownMenuItem replaces ThemeDropdownMenuRadioGroup. It is the toggle in menu form: one "Theme" row that ends in the ThemeSwitcher icon. Put it at the top level of an account menu, with no submenu. A press flips light and dark the same way a ThemeSwitcher click does, and the "System Preference" option is gone. The menu stays open, and the new theme grows in a circle from the icon. Under forceTheme, the item is disabled. ThemeDropdownMenuItemProps replaces ThemeDropdownMenuRadioGroupProps, and a label prop replaces aria-label and aria-labelledby.
    • A forced page disables the toggle. Under a ThemeProvider with forceTheme, useAppliedTheme returns the forced theme, and ThemeSwitcher shows that theme and is disabled. A click there changed only a preference the page did not show.

Patch Changes

  • #1602 b304302 Thanks @cody-dot-js! - Fix chart y-axis labels that overlap at short plot heights, and fix other chart papercuts.

    • YAxis fits its ticks to the plot height. A vertical value axis keeps about 28px for each tick, and it skips a label that would overlap its neighbor. A 64px chart over a [0, 200] domain now paints 0 and 200, not five stacked labels.

    • YAxis tickCount is a maximum. The axis selects a larger step when the human-friendly step gives more ticks than you asked for: tickCount={3} over [0, 200] paints 0, 100, 200, not 0, 50, 100, 150, 200. When you omit tickCount, the axis still aims for about 5 ticks.

    • tickFormat receives a second argument. On YAxis it is { defaultLabel, step }. On XAxis it is { defaultLabel }. defaultLabel is the label the axis paints without a formatter, and its precision follows the step, so a formatter that adds a unit no longer has to guess the precision: tickFormat={(value, { defaultLabel }) => `$${defaultLabel}`}. Formatters that take one argument keep working.

    • Legend no longer takes the plot's height. The legend is at most 64px tall (max-h-16) and scrolls past that, so a chart with many series keeps its plot in a fixed-height Root. Set a max-h-* class in className to change the bound. When the legend scrolls, it is in the Tab order, so a keyboard user can scroll it.

    • A dataKey, xKey, or zKey typo error lists the keys of every row. It listed only the keys of the first row, so a key that only later rows carry was missing from the list.

    • dist/llms.txt says that it is an index. It points agents at the bundled .d.ts files for props.

    • A streaming chart paints faster. The left-edge fade no longer copies the whole canvas each frame. It paints the grid beneath the faded marks, so the chart keeps one canvas, not two.

    • A live line or area chart scrolls at one constant speed. When appends arrive at a steady rate, the chart measures the time between them and glides the x domain to each new target over that interval. The domain chased each target, so the chart surged right after an append and nearly stopped before the next one. In a 60-point stream with an append every 200ms, the fastest frame scrolled 11.6 times as far as the slowest, and 8.4% of frames stalled. The fastest frame now scrolls 1.05 times as far as the slowest, and no frame stalls. When an append does not move the x domain, the chart does not paint. A data swap, reduced motion, and animate={false} keep their behavior.

    • Charts paint and ingest with less CPU and less garbage. Axis labels are formatted and measured once, not on every frame, and measured again when a web font loads. Bars reuse their geometry objects across frames. Same-alpha gridlines share one stroke. A continuous-x ingest uses typed arrays, not one object per row. A pointer hover no longer rewrites unchanged hover-dot styles, and it no longer re-renders the screen-reader announcer. An axis, grid, or tooltip registration no longer re-renders the legend or the data table. Measured in headless Chromium with React's production build (mean of 3 runs):

      ScenarioMetricBeforeAfter
      800-category × 3-series bar chart, tweenmain-thread ms per frame0.690.29
      chart-code KB allocated per frame23629
      measureText calls per frame250
      20k-point area chart, window slidesms per data update (React + ingest)5.402.65
      chart-code KB allocated per frame840253
      4-series line chart, pointer sweepsetAttribute calls per pointer move4.10.13
  • #1605 6ff2f24 Thanks @cody-dot-js! - Shake a form control once when it enters the error state. The control moves 4px and settles in 300ms. It does not shake again while the error stays, on focus, or on blur. It shakes again only after the error clears and comes back. With reduced motion, the control does not move.

    • Input, PasswordInput, TextArea, Combobox.Input, MultiSelect.Trigger, and Select.Trigger shake when validation becomes "error".
    • An Input whose InputCapture alone has validation="error" now shows the red border and shakes. Before, the outer chrome stayed gray.
    • MultiSelect.Input now gets aria-invalid from the validation and aria-invalid props on MultiSelect.Trigger. Before, a trigger outside a Field.Item showed the error but did not report it to assistive technology.
    • Checkbox and Switch do not shake.
    • The new animate-shake utility applies the same motion to your own control.
  • #1597 f6fc775 Thanks @cody-dot-js! - Bump @tanstack/react-virtual from 3.14.21 to 3.14.22.

  • #1599 a913c10 Thanks @cody-dot-js! - RadioGroup.Button keeps its label on one line. A long label no longer wraps and spills out of the fixed-height segment; the segment grows to fit the label instead.

  • #1603 e0aec73 Thanks @cody-dot-js! - ThemeProvider shares the theme cookie across ngrok.ai and its subdomains, such as app.ngrok.ai. It sets domain=.ngrok.ai on those hosts, as it sets domain=.ngrok.com on ngrok.com hosts. A theme choice on app.ngrok.ai now also applies on ngrok.ai. One cookie cannot span ngrok.com and ngrok.ai, so each site keeps its own choice.

  • #1601 1592f2e Thanks @cody-dot-js! - ThemeSwitcher animates the theme swap. The new theme grows in a circle from the button in a 400ms view transition, so every color changes in the same frame. Color transitions stop until the swap ends. Under prefers-reduced-motion: reduce, or in a browser without view transitions, the swap happens at once.

    The reveal is part of setTheme from useTheme, so every theme change in an app runs it, such as a command palette action or a settings page. setTheme takes a second argument, { origin }, the element the circle grows from. When it is omitted, the circle grows from the focused element, else from the center of the viewport. setTheme also takes an updater, (theme) => next, that reads the latest theme when two presses come before the first swap runs. A dialog overlay or a dropdown menu that closes as the swap starts leaves at once, so it does not freeze outside the circle. The new SetThemeOptions type describes the options.

0.90.4

Patch Changes

  • #1595 02a61a1 Thanks @cody-dot-js! - Add a column width setting to DataTable. Set meta: { width: "fit" } on a column, and pass column to its DataTable.Header and to each DataTable.Cell. The column is then only as wide as its widest value, on one line, and the spare width goes to the other columns. You no longer need className="w-px" on the header and className="whitespace-nowrap" on each cell.

    • DataTable.Cell is now a DataTable part. It takes an optional column prop, and its data-slot is "data-table-cell table-cell".
    • DataTable.Header and DataTable.Cell stamp data-column-width="fit" on a fit column.
    • mantle adds width to TanStack Table's global ColumnMeta interface. A table with a columnMeta slot must add the new DataTableColumnMeta type to its slot type.

    See https://mantle.ngrok.com/components/data-display/data-table#sizing-a-column-to-its-content.

0.90.3

Patch Changes

  • #1591 5ba2ca6 Thanks @cody-dot-js! - Keep Accordion.Trigger the same size when its section opens. The trigger no longer moves 8px of its bottom padding to Accordion.Body, so the header and the gap to the content stay still while only Accordion.Content moves. Accordion.Body now has pt-1, which keeps the focus ring off the first line. The content height and opacity, and the caret, ease with the strong ease-out curve. The caret no longer turns when the user prefers reduced motion. Remove a data-state-open:pb-4 override from Accordion.Trigger; it is now the default.

  • #1591 5ba2ca6 Thanks @cody-dot-js! - Add DataTable.ControlCell, a <td> for a row's expand toggle or selection checkbox. A click on its padding focuses and clicks the control, and no click in it reaches a clickable row. The control aligns with the first line of a multi-line row. The cell shows a pointer cursor while it holds an enabled control. When its control is disabled, a click reaches the row.

    Support sub-rows in DataTable.RowExpandButton. For a row with sub-rows, the accessible label is Expand … / Collapse …. The button sets aria-controls only when the row also renders a detail panel through renderExpanded. Previously it pointed aria-controls at a missing element.

    When DataTable.RowExpandButton expands a row, its sub-rows and DataTable.ExpandedRow fade in and the rows below slide down, with a 200 ms strong ease-out. When it collapses a row, that content fades out while the rows below slide up over it, and a click during the collapse reverses it. The motion is off when the user prefers reduced motion.

  • #1594 c2c9c6e Thanks @cody-dot-js! - Stop a closed AppLayout.SidePanel from sliding when the viewport crosses mobileBreakpoint or the rail changes. When a close ends, the transition stays off until the next open, so the closed panel changes presentation at once. An open and a close still slide, and a merged duration-* still sets how long a close runs.

0.90.2

Patch Changes

  • #1581 d781768 Thanks @cody-dot-js! - Add a 2xs size to IconButton: a 20px box with a 16px icon. Use it for a copy button or another action inside a DescriptionList row, so the row keeps the same height as a row without an action. 2xs is for IconButton only; Button and SplitButton keep the shared xs to xl scale.

  • #1580 49e26df Thanks @dependabot! - Bump @tanstack/react-virtual from 3.14.19 to 3.14.21.

  • #1585 d5ce63a Thanks @cody-dot-js! - OtpInput.Root: stop password managers from taking over the OTP input. The hidden input now carries the ignore attributes for 1Password, Bitwarden, Dashlane, LastPass, and Proton Pass, so their badges and fill prompts stay off the slots. To let one fill the code, pass its attribute as undefined.

  • #1576 a3546a4 Thanks @cody-dot-js! - Command.SearchTrigger now seeds the palette query with a typed character that is more than one code point, such as a skin-tone emoji or a decomposed accent. Before, searchActionFromKeyDown counted code points, so it read such a key as a named key and dropped the character.

0.90.1

Patch Changes

  • #1572 869cd53 Thanks @cody-dot-js! - Strata.Section sizes its columns minmax(0, 1fr) minmax(0, 2fr) instead of 1fr 2fr, and below md it sizes its one stacked track minmax(0, 1fr) instead of the implicit auto. A bare 1fr track and an implicit track both have an auto minimum that resolves to the content's min-content width, so one nowrap value in Strata.Body (a URL, an ID, a token) widened the section past its container and the page scrolled sideways. Each call site patched both columns with min-w-0.

    The tracks now shrink to the room the section has, at every width. A truncate child clips with an ellipsis, and wrap-anywhere or break-all wraps, with no min-w-0 on Strata.Header or Strata.Body. Remove a min-w-0 you set on either part for this reason; it is now redundant. The split is still one to two, and --strata-columns still re-splits it. Keep the minmax(0, …) form in an override: --strata-columns: 1fr 1fr brings the overflow back.

    Docs: https://mantle.ngrok.com/components/structure/strata#column-split

  • #1572 869cd53 Thanks @cody-dot-js! - Tabs.Root calls onValueChange once per activation. Radix activates a trigger on mousedown and moves focus to it in the same handler, and the focus activates it again before a controlled root re-renders, so a controlled Tabs.Root heard the same value twice in one dispatch. A tab strip that navigates in onValueChange then navigated twice, and a strip that writes a URL param wrote it twice.

    The root now passes a repeated value through only when the dispatch has ended: a later press on the same trigger, which a consumer that kept the old value sees as a new request, still fires. Remove a guard you added for this in onValueChange; it is now redundant.

  • #1572 869cd53 Thanks @cody-dot-js! - TruncatedText stands down inside a control. While its value overflowed, the root became a tab stop and a tooltip trigger wherever it rendered, so a value inside a <button>, an <a href>, an Accordion.Trigger, a RadioGroup item, a menu item, or a tab nested a second focusable element in the control, which is invalid HTML and a second Tab stop for the same thing.

    Inside a control the root now takes no tabIndex and renders no tooltip, so it stamps no data-state. The segments still clip, data-truncated still lands, and the visually hidden copy still gives the control's accessible name the whole value. A tabIndex prop still wins. Wrap the control in Tooltip when a sighted reader needs the full value; the root no longer stops that tooltip from opening while its value fits. The server render and the first client render assume no control, like they assume no overflow. After that, every commit checks again from the root's parent, so a root that asChild renders as a control is not inside one, and a control that gains or loses its href or role around a mounted root takes effect without a remount.

    Docs: https://mantle.ngrok.com/components/data-display/truncated-text#accessibility

0.90.0

Minor Changes

  • #1404 2e5a336 Thanks @cody-dot-js! - Add Strata, a page of titled sections where each one's title and description sit beside its content. It is the shape of a settings page, a resource configuration editor, and any form long enough that each group needs its own sentence — see the docs.

    import { Strata } from "@ngrok/mantle/strata"; <Strata.Root>	<Strata.Section>		<Strata.Header>			<Strata.Title>Providers</Strata.Title>			<Strata.Description>What providers keys may call.</Strata.Description>		</Strata.Header>		<Strata.Body>			<ProviderScopePicker />		</Strata.Body>	</Strata.Section>	<Strata.Section>		<Strata.Header>			<Strata.Title>Routing rules</Strata.Title>			<Strata.Description>Define how requests are authenticated.</Strata.Description>		</Strata.Header>		<Strata.Body>			<RoutingRuleList />		</Strata.Body>	</Strata.Section></Strata.Root>;

    The parts:

    • Root is the stack. It rules a hairline between adjacent Section children — never above the first or below the last — and it scopes --strata-columns for all of them.
    • Section is one titled band, and it owns its own vertical rhythm (py-8 first:pt-0) rather than taking it from Root, so a lone section reads correctly. Below the md breakpoint it stacks Header above Body; at md and up it splits the two into a 1fr 2fr grid.
    • Header is the introductory column — Title and Description, spaced by gap-2. It renders a semantic <header>, which is generic rather than a second banner landmark because it sits inside Section's <section>.
    • Title renders an <h2>, one level below the page's <h1>. Compose asChild with the right element when the sections sit deeper in the outline.
    • Description renders a <p> in muted body text. Optional.
    • Body is the content column, and its gap-4 spaces stacked controls.

    Section takes no accessible name, so a page of sections adds no region landmarks — the Title headings already carry the outline, and a landmark list only stays useful while main and nav are easy to find in it. Set aria-labelledby to the Title's id only when a section is a destination a reader jumps back to. To tell apart controls that read alike across two sections, reach for Field.Set and Field.Legend instead — assistive technology announces a landmark on entry, never per control.

    Public CSS variable:

    CSS VariableDefaultDescription
    --strata-columns1fr 2frThe grid-template-columns value at md and up. Set it on Root to re-split every section, or on one Section to move only that one.

    Every part stamps a data-slot (strata, strata-section, strata-header, strata-title, strata-description, strata-body) and accepts asChild.

Patch Changes

  • #1367 4a976be Thanks @cody-dot-js! - countryCodes no longer lists "016" twice. The list had 739 entries for 738 distinct codes.

  • #1563 79ca242 Thanks @cody-dot-js! - Add three hooks to @ngrok/mantle/hooks for a surface that turns modal or dismissible in place, with no portal and no remount. AppLayout.SidePanel composes all three; see the hooks page.

    • useFocusTrap(ref, { active }) keeps keyboard focus inside an element: on activation it moves focus to the first tab stop inside, else to the element itself (which gets tabindex="-1" for the trap's lifetime when it has none), and retries over frames when the element opens with a transition and holds nothing focusable yet. While active, Tab on the last tab stop wraps to the first and Shift+Tab on the first wraps to the last, in document order, and a child that prevents the default on Tab keeps it. It traps the keyboard only, so a menu or popover that portals out of the element keeps working; pair it with useInertSiblings to block pointer focus. Flip active to engage and release the trap on one mounted element.
    • useInertSiblings(ref, { active }) sets inert on every other child of the element's parent while active, including a sibling that mounts later, and clears what it set on release. It reaches siblings only, so a notice above the row stays usable.
    • useSwipeToDismiss(handleRef, { target, direction, property, threshold, flickVelocity, active, onDismiss }) makes an element a swipe handle that drags target toward one edge and calls onDismiss past 25% of its size or on a flick faster than 0.4 CSS pixels per millisecond. The target follows the pointer one to one, a shorter drag springs back on the target's own transition, a drag the wrong way meets rubber-band resistance, and a gesture that runs across the direction never starts. property picks what the drag writes: translate slides the target, and flex-basis shrinks a flex item the way a lane collapses. property and active take a function for a condition CSS owns, such as a panel that floats under one media query and sits in a lane under another.
  • #1564 2cfa2d3 Thanks @cody-dot-js! - Bump the runtime dependencies to their latest patch release:

    • @ariakit/react from 0.4.41 to 0.4.42
    • @tanstack/react-virtual from 3.14.18 to 3.14.19

    No API change.

  • #1562 4915582 Thanks @cody-dot-js! - Ship the migration guides as ast-grep codemods. Every migration under /migrations that has a syntactic rewrite now ships its rules in the package at dist/migrations/<NNNN>-<slug>.yml, and each guide opens with a Codemod section that names what the rules apply and what stays manual.

    Run one against your source from a @ngrok/mantle install:

    npx --yes --package=@ast-grep/cli@0.50.0 ast-grep scan --rule node_modules/@ngrok/mantle/dist/migrations/0008-tabs-separator.yml --update-all src

    From a mantle checkout, pnpm -w run migrate 0008 <path> runs the same rules, and --dry-run prints the rewrites first. Run your formatter after either command: a codemod keeps the surrounding layout as it found it.

  • #1562 4915582 Thanks @cody-dot-js! - dist/agent.json now carries the full agent manifest: every component with its import path, status, summary, JSDoc summary, @example blocks, search aliases, and sibling boundaries, plus the hooks and utilities with their examples. dist/llms.txt lists every module on one line with its import path and summary. Both match what https://mantle.ngrok.com/api/components.json serves, so an agent with no network reads the same records from node_modules.

    Card, Well, Separator, and Empty now state their sibling boundaries in @useWhen and @siblings JSDoc tags. The manifest exposes them as useWhen and siblings, and the docs pages carry a matching "When to use" section.

  • #1563 79ca242 Thanks @cody-dot-js! - AppLayout.SidePanel now floats over the card whenever the AppLayout.Workspace row is too narrow to hold the rail, the card, and the panel at full width, whatever the viewport is. Before, only mobileBreakpoint decided, so an expanded rail at a tablet width squeezed the page narrower than the panel beside it.

    AppLayout.Workspace is the container named app-layout-workspace, and the threshold follows the rail beside the card, read through :has() on Sidebar.Nav's data-slot and data-state:

    • --container-side-panel-lane (56.5rem: the expanded rail at 13rem, the card at the panel's floor of 21rem, the panel at 21rem, and the three gutters) while a direct child of the row is an expanded Sidebar.Nav.
    • --container-side-panel-lane-icon-rail (46.75rem: the same row with the icon rail at 3.25rem) with a collapsed rail, no rail, or a rail that is a sheet.

    So collapsing the rail at a tablet width returns the panel to the lane beside the page. Raise either token in @theme for a wider rail or panel.

    The panel's widths now read the row, not the viewport. The default --app-layout-side-panel-width is clamp(21rem, 24cqw, 27rem), a quarter of the row, so a row that --app-layout-max-width caps on a wide screen holds the panel at its 21rem floor and the page keeps the rest. Before, 24vw of a 1920px viewport put a 27rem panel beside a page narrower than the panel. The floating strip is min(24rem, 100cqw - 5rem), so a row narrower than the strip never holds a panel wider than itself.

  • #1563 79ca242 Thanks @cody-dot-js! - AppLayout.SidePanel can now be swiped away in every presentation, a floating one is an overlay the reader can dismiss, and a covering one is modal.

    • AppLayout.SidePanelHeader is a swipe handle, through the new useSwipeToDismiss hook. A drag toward the end edge follows the pointer one to one: a floating panel slides, and a panel in the lane shrinks the way its close animation does, so the page grows under the finger. A release past 25% of the panel's width, or a flick, closes it. A shorter drag springs back on the panel's own 200ms ease-out, so motion-reduce: turns it into a cut, and a drag the wrong way meets rubber-band resistance. The row is touch-pan-y, so a vertical swipe still scrolls, and a tap on a control in the row stays a tap.
    • Escape closes a floating panel while focus is inside it. An open combobox or popover inside the panel takes the first Escape, and the next one closes the panel. The root then returns focus to the trigger outside, as on every close. A panel in the lane is a docked pane and ignores Escape.
    • A covering panel is modal. When the panel covers the row from gutter to gutter (one breakpoint below mobileBreakpoint), the row's other children, the card and the rail, go inert while it is open, and a focus trap wraps Tab inside it and moves focus into it on open. The one <aside> stays mounted across the lane, the float, and the cover, so a conversation keeps its DOM and the trap turns on and off with the viewport. The complementary landmark keeps its name.
  • #1560 bd24dff Thanks @cody-dot-js! - Sidebar.Trigger and AppLayout.SidePanelTrigger now default to size="sm", so they sit level with the other size="sm" ghost icon buttons in an AppLayout.Header row. Both parts take a size prop to change it, and IconButton's data-size reflects the value. Pass size="md" to keep the old height.

0.89.0

Minor Changes

  • #1557 5221685 Thanks @cody-dot-js! - Button and IconButton now stamp data-disabled and data-loading by presence alone: data-disabled="" while the button resolves disabled (disabled, aria-disabled="true", or isLoading with no explicit disabled), and data-loading="" while isLoading. An enabled, idle button carries neither attribute, and disabled={false} keeps a loading button enabled with data-loading alone. Before, every button carried both with a "true" or "false" value, and mantle's data-disabled: variant matches the attribute's presence, so a consumer's data-disabled: style fired on every button.

    Migration: a selector on a value no longer matches: [data-disabled="true"], [data-disabled="false"], [data-loading="true"], and [data-loading="false"]. Match presence instead: [data-disabled] and [data-loading], or the data-disabled: variant, and :not([data-disabled]) for the enabled state. Sandbar.SaveButton and Sandbar.DiscardButton reflect the same two attributes from the Button they render.

  • #1547 550b9c9 Thanks @cody-dot-js! - Breaking: a chart's screen-reader data table mounts once focus lands inside the chart, not at mount. Every interactive LineChart.Root, BarChart.Root, AreaChart.Root, and ScatterPlot.Root now mounts dormant. A dormant chart ingests its data, throws its dataKey and xKey invariants (on a continuous x scale, against the rows with a finite x, the rows the chart plots), and answers the keyboard. It mounts the sr-only data table once it engages: once focus lands inside it (the interaction overlay is the Tab stop, and a focusable part composed inside the Root, such as CopyButton, counts too). It never goes dormant again. Visibility never engages a chart: a chart that scrolls into view paints, and it mounts no table. The Root stamps data-state="dormant" and then data-state="engaged" after the props spread, so a data-state you pass does not override it.

    Why: on a page of 100 line charts, the sr-only table was 162 of each chart's 171 DOM nodes, every chart painted at mount, and the 100 canvases held 535 MB of backing store at DPR 2. A reader sees about four of them. A list page is worse: it renders a sparkline chart in every table cell, two per row, forty in view at once. A table that mounted on visibility would add thousands of nodes nobody asked for, and a screen reader navigating that list would meet a nested table inside every cell. The always-rendered description names what each chart holds, Tab into a chart gives the full table, and the keyboard point cursor reads every value.

    Paint has its own gate, and it never mounts the table. A dormant chart is a shell: the wrapper, the plot box, the interaction overlay, its description, and the live region. The <canvas>, the crosshair, the hover band, and the markers (data-slot="<kind>-canvas", "-crosshair", "-hover-band", "-markers") are absent from server HTML and from a dormant chart; the Root inserts them, outside React, when the chart activates: within one viewport of the visible area (in every direction, nested scroll containers included), on focus, or before print. A page that activates many charts at once runs them in slices of a few milliseconds, on-screen charts first, and yields between slices, so a table of 200 sparklines never blocks input for a frame. An activated chart resolves its colors, allocates its canvas, and paints in the same task. A pointer press on a chart whose activation is still queued activates it first, so the first click on a chart activates its datum. Every chart in a document reports its size through one shared ResizeObserver, and the chart module builds its Intl formatters on first use instead of at import. beforeprint activates and paints every chart synchronously, and afterprint releases the ones out of view. Focus inside a chart also activates it, so a chart focused with preventScroll is not blank, and it stays painted while it holds focus; on blur the viewport gate owns it again, and a chart beyond the far band gives up its canvas. Three viewports away a chart releases its canvas memory, and it repaints on the way back. On a browser without IntersectionObserver's scrollMargin, a chart inside a scrolling panel observes the near and far bands from the panel, and the panel's own position gates them: a panel below the fold activates nothing, and a panel that scrolls away releases its charts. The enter animation plays when a reader first sees a chart, not when it mounted offscreen. A chart inside a hidden tab paints when the tab shows it. The tooltip surface (data-slot="<kind>-tooltip") mounts on the first hover or keyboard step and stays; a chart nobody points at holds none. A list of 100 charts now pays for the handful a reader can see.

    A new sr-only description is the interaction overlay's single aria-describedby target, and it replaces the unslotted instructions span. It renders on every interactive chart from the first render at data-slot="line-chart-description", "bar-chart-description", "area-chart-description", or "scatter-plot-description". Its text is the chart kind, the number of data points (categories on a bar chart; on a continuous x scale, the rows with a finite x, the ones the chart plots), the x range from the first point to the last (at X for one point), the series labels, and then the unchanged keyboard instructions: "Line chart with 30 data points from Jul 1, 2026 to Jul 30, 2026. Series: Requests, Errors. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout." With no data it reads "Line chart with no data." in place of the count and range. Server HTML carries the plain count alone. The range waits for hydration, because Intl output differs by locale, and the series labels register in layout effects, so both render on the client. A dormant chart has no table yet, so this sentence is what a screen reader gets from it.

    A decorative chart renders no description, no table, and no data-state: it has no overlay, so focus can never engage it. It paints on the same visibility gate as an interactive chart. A Root that turns interactive later mounts its description then, series labels included, and a Root that turns decorative unmounts its tooltip surface. The polite live region (role="status") still mounts with the Root.

    Migrate a test suite that queries the canvas, the hover layers, or the tooltip surface at mount:

    • Under a real browser, the canvas and the hover layers arrive in the observer task after mount: await waitFor(() => expect(container.querySelector("canvas")).toBeInTheDocument()). Under the happy-dom stub @ngrok/mantle ships for its own tests, they are there after render().
    • The tooltip surface exists after the first hover or keyboard step: await user.tab(); await user.keyboard("{ArrowRight}") before you query data-slot="<kind>-tooltip".

    Migrate a test suite that queries the table at mount:

    • Focus the chart before you query the table: await user.tab(), or element.focus() inside act on the role="application" overlay, then getByRole("table"). await findByRole("table") covers a focus that lands asynchronously.
    • A stubbed IntersectionObserver under happy-dom, or no IntersectionObserver at all under jsdom, paints the chart and mounts no table. A stub is no longer a way to reach the table.
    • A real browser mounts the table on the same focus, so a chart in view still needs a Tab or a focus() call before its table exists.

    An agent that reads chart data from the DOM focuses the chart, waits for data-state="engaged" on the Root, then reads the data-slot="<kind>-data-table" element. Scrolling the chart into view is not enough. CopyButton hands over the same rows as markdown with neither step.

    Docs: https://mantle.ngrok.com/components/charts/line-chart#accessibility, https://mantle.ngrok.com/components/charts/bar-chart#accessibility, https://mantle.ngrok.com/components/charts/area-chart#accessibility, https://mantle.ngrok.com/components/charts/scatter-plot#accessibility

  • #1547 550b9c9 Thanks @cody-dot-js! - Breaking: the chart Roots take mode in place of the decorative boolean. LineChart.Root, BarChart.Root, AreaChart.Root, and ScatterPlot.Root take mode?: "interactive" | "static" | "decorative". "interactive" is the default and is the chart as it always was. "decorative" is the old decorative behavior under a new name, with one change: its plot now holds the canvas alone, with no hover layers (data-slot="<kind>-crosshair", "-hover-band", "-markers"), because nothing can hover it. "static" is new. Replace decorative with mode="decorative"; nothing else about a decorative chart changes. The migration guide is at https://mantle.ngrok.com/migrations/0009-chart-root-mode-migration.

    mode="static" is a chart a reader glances at while the exact values sit beside it as text: a sparkline in a table row or a card, a trend in a print report. The plot is a named role="img", with the same aria-label or aria-labelledby an interactive chart takes and an aria-describedby to the description (the chart kind, the count, the x range, and the series labels, without the keyboard instructions), so a screen reader hears what the chart holds without a Tab stop. There is no interaction overlay: no hover band, no tooltip, no keyboard point cursor, no data table, no live region, and no data-state. At activation its plot holds the canvas alone; the crosshair, hover band, and marker layers never mount. A composed Tooltip part has no effect. Pointer events pass through to the row or card behind it. The series keep their colors. animate defaults to false in static mode, because a list of sparklines that each play a reveal as the reader scrolls is motion with no information in it; pass animate to play it. The interaction props (activeIndex, onActiveIndexChange, onDatumActivate) do not compile on a static chart, and a name is required, as on an interactive one. A mode picked at runtime must satisfy every mode it can take: mode={isPlaceholder ? "decorative" : "interactive"} still needs a name. A static or decorative Root type (LineChartRootProps<Row, "static">) requires mode.

    Why: noninteractive is not decorative. A sparkline beside a total still tells a reader when activity happened and how it varied, so it keeps a name and a description, and only its values live in the text beside it. One hundred sparklines in a table as one hundred Tab stops is a focus order no reader wants, and a readout only a pointer can reach fails WCAG 1.4.13, so a static chart offers neither. A chart that is the only source of its values stays interactive.

    The mode prop's JSDoc carries a table of the three modes with the use case and the reader's experience of each, and a full-tree example per mode. ChartMode is exported from every chart family. The Sparklines in a List recipe now renders static sparklines.

    Migrate a test suite that reaches a sparkline:

    • A static chart is getByRole("img", { name }), not getByRole("application", …), and user.tab() skips it.
    • A static chart mounts no data table and no tooltip surface, so a test that waited for either on a sparkline asserts the description instead: expect(image).toHaveAccessibleDescription(/^Bar chart with 30 categories/).

    Docs: https://mantle.ngrok.com/components/charts/line-chart#modes, https://mantle.ngrok.com/components/charts/bar-chart#modes, https://mantle.ngrok.com/components/charts/area-chart#modes, https://mantle.ngrok.com/components/charts/scatter-plot#modes

  • #1550 dbf2900 Thanks @cody-dot-js! - Add TruncatedText, which renders one long opaque value (a URL, a resource ID, an image reference, a token) on a single line. When the value overflows its container, the head clips with one ellipsis and the last tail graphemes (8 by default) stay visible, so the part that tells two values apart never disappears. When the value fits, it renders plain. When the container is narrower than the tail itself, the tail clips at its start, so the end of the value is the last thing to go. A tooltip shows the full value on hover only while the value overflows. Like every mantle tooltip, it needs a TooltipProvider ancestor. The root carries user-select: all, so one click selects the whole value across both segments, and a copy yields the full string with no ellipsis and no line break. A visually hidden copy of the value is what assistive technology reads, as one string, and the two visible segments are hidden from it. The root, the head, the tail, and the hidden copy stamp data-slot="truncated-text", "truncated-text-head", "truncated-text-tail", and "truncated-text-value". While the value overflows, the root stamps data-truncated. The root carries translate="no" by default. Pass asChild to render the value inside another element, such as a <code>. Import it from @ngrok/mantle/truncated-text.

    Docs: https://mantle.ngrok.com/components/data-display/truncated-text

Patch Changes

  • #1555 5b0389b Thanks @cody-dot-js! - AppLayout gains a side panel, its state owner and trigger, cookie persistence for it, and a width cap for wide screens.

    AppLayout.SidePanel (data-slot="app-layout-side-panel") is a second card after AppLayout.Content, for an assistant, a details pane, or any surface that opens beside the page and closes again. It renders an <aside>, the complementary landmark, with the same card treatment as the content card, and reads like a Sheet with no focus trap and no backdrop. Three parts fill it: AppLayout.SidePanelHeader (data-slot="app-layout-side-panel-header"), the toolbar row on the same band as AppLayout.Header so the two rows center on one line; AppLayout.SidePanelBody (data-slot="app-layout-side-panel-body"), the scroll region, the same box as AppLayout.Body with no landmark; and an optional AppLayout.SidePanelFooter (data-slot="app-layout-side-panel-footer"), a bar pinned to the bottom edge outside the scroll region.

    AppLayout.SidePanelRoot owns whether the panel is open, the way Sidebar.Root owns the rail: open + onOpenChange controlled, or defaultOpen uncontrolled, closed by default. It is renderless; wrap AppLayout.Workspace with it. AppLayout.SidePanelTrigger (data-slot="app-layout-side-panel-trigger") is the ghost IconButton that toggles it, with aria-expanded, aria-controls, data-state, and a tooltip with its label, the same shape as Sidebar.Trigger; it needs a TooltipProvider ancestor. useSidePanel() returns open, setOpen, toggle, and panelId for a custom control; setOpen keeps one identity across renders, so it is safe in an effect dependency list. When the panel closes while focus is inside it, the root moves focus to the first trigger outside the panel that can take focus. The return runs on every close, whether a trigger, setOpen, or a controlled parent closed the panel. A trigger that CSS hides at the current breakpoint, or a disabled one, cannot take focus either, so a responsive header with one trigger per breakpoint lands on the visible one.

    Open, the panel takes --app-layout-side-panel-width beside the card (clamp(21rem, 24vw, 27rem)) and the card absorbs the difference. Closed, it collapses to nothing and goes inert, with its children still mounted so a conversation keeps its scroll position and its in-flight reply across a close and a reopen. The change animates flex-basis alone over 200ms on the new --ease-out-strong theme token (cubic-bezier(0.23, 1, 0.32, 1)), the way the rail collapses: the <aside> is a clip box and the card is an inner column pinned at the open width, so nothing inside reflows or moves, a second click mid-flight reverses the run, motion-reduce: turns it into a cut, and the transition holds until one frame after hydration (data-hydrated) so a state the client corrects after the server render snaps instead of sliding. Below mobileBreakpoint ("sm", "md", or "lg", "lg" by default) the panel floats over the card. Closed, it slides off the end edge and goes invisible the instant the slide ends (visibility rides the transition), so a row that --app-layout-max-width caps inside the frame has no panel strip beside it. One breakpoint lower it covers the card from gutter to gutter. With "sm" there is no lower breakpoint, so the panel covers the card from gutter to gutter as soon as it floats. The panel stamps data-state="open" | "closed" and data-hydrated.

    extractSidePanelStateCookie, serializeSidePanelStateCookie, and SIDE_PANEL_STATE_COOKIE_NAME ("mantle-side-panel-state") persist the state to a cookie the server can read, the same pair the sidebar ships: read the cookie in the loader into defaultOpen, and write it from onOpenChange, so the server HTML carries the right state with no first-frame correction.

    AppLayout.Workspace now caps at --app-layout-max-width (128rem, 2048px) and centers in the frame, so an ultrawide monitor gets a centered shell instead of a card stretched across it. The cap is the width at which a 13rem rail, a max-w-7xl page with its padding, and an open side panel at its 27rem cap are each at full size. AppLayout.Root still paints bg-base edge to edge, and AppLayout.Notice still spans the full window. Set the variable to none on AppLayout.Root to let the row fill any width, and merge className="ml-0" on the workspace to pin the row to the start edge. The workspace is also relative and min-w-0, so a floating side panel resolves against the row and a nested row cannot push the panel off the window.

    Docs: https://mantle.ngrok.com/layouts/app-layout#side-panel

  • #1547 550b9c9 Thanks @cody-dot-js! - A chart resolves its theme tokens from the Root's computed style in one read. The computed value of a custom property arrives with every var() substituted, and the canvas parses the oklch() and color-mix() strings mantle's tokens resolve to, so one getComputedStyle read of the Root resolves every series and chrome color inside that Root's own cascade. A --color-chart-N override scoped to one Root still wins for that chart alone, as before. A bare currentColor resolves from the Root's computed color. A custom ReferenceLine color resolves through the same read. A hidden probe element now exists only as a fallback for a value the canvas cannot parse, such as light-dark() or a currentColor nested inside color-mix(), and it mounts inside the plot box (data-slot="<kind>-plot"), not in the Root's own child list, so a structural selector on the Root's children never counts it.

    Before, every color read appended a probe <span> to the Root, read its computed color, and removed it, and each insertion forced a style recalculation of the whole chart subtree: 17 per chart, 1,704 for a list of 100 charts, 177 ms of style time at mount on an M3 Max. A chart now costs one style read per resolution pass. One theme observer per document invalidates the colors, and it ignores a same-value attribute rewrite. A new className or style on the Root invalidates them too, so a --color-chart-N or color override that changes after the first paint repaints; an equal inline style object on a re-render does not.

  • #1559 dd7e5de Thanks @cody-dot-js! - Dialog.Header now stacks Dialog.Title and Dialog.Description in a column and keeps every other child, Dialog.CloseIconButton included, on the title's row. The header was one unwrapped flex row, so the composition the docs show put the description beside the title and the title shrank to a few characters. A long title still truncates beside the close button, and a description in Dialog.Body renders as before.

    The stack keys on the data-slot of the header's direct children. A wrapper around the title and the description owns their layout, so the min-w-0 flex-1 wrapper workaround still works.

  • #1557 5221685 Thanks @cody-dot-js! - Every part now joins an incoming data-slot ahead of its own instead of replacing it, the contract COMPONENT_SPEC.md §3.2 states and that Sidebar, Tabs, Select, and AppLayout already kept. A data-slot reaches a part two ways: mantle's Slot forwards an asChild ancestor's chain, and a wrapper passes its own name as a prop. The Slot path already joined. The prop path replaced one name with the other, so a wrapper's surface lost the inner part's slot: Sidebar.Trigger's tooltip read sidebar-trigger-tooltip with no tooltip-content, Sandbar.SaveButton read sandbar-save-button with no button, and DataTable.Root read data-table with no table.

    Each such surface now carries the chain in DOM order, ancestors first: sidebar-trigger-tooltip tooltip-content, sandbar-save-button button, data-table table, alert-center-bar alert, icon svg-only, and so on. Every part accepts data-slot in its props type through WithDataSlot, and each part's docs table shows the value it renders.

    A selector on [data-slot~="button"] now reaches a Sandbar.SaveButton, and [data-slot~="sandbar-save-button"] still matches it. An exact [data-slot="sandbar-save-button"] match does not, and exact matching was never the documented way to target a slot. A Tailwind variant of the form [&_[data-slot=x]] is an exact match too: write [&_[data-slot~=x]].

  • #1546 e42a878 Thanks @dependabot! - Bump the runtime dependencies to their latest patch or minor release:

    • @ariakit/react from 0.4.40 to 0.4.41
    • @radix-ui/react-dialog from 1.1.23 to 1.3.1
    • @radix-ui/react-dropdown-menu from 2.1.24 to 2.2.1
    • @radix-ui/react-hover-card from 1.1.23 to 1.2.1
    • @radix-ui/react-popover from 1.1.23 to 1.3.1
    • @radix-ui/react-progress from 1.1.16 to 1.2.1
    • @radix-ui/react-select from 2.3.7 to 2.4.1
    • @radix-ui/react-slider from 1.4.7 to 1.6.1
    • @radix-ui/react-slot from 1.3.3 to 1.5.0
    • @radix-ui/react-switch from 1.3.7 to 1.4.1
    • @radix-ui/react-tabs from 1.1.21 to 1.2.1
    • @radix-ui/react-tooltip from 1.2.16 to 1.4.1
    • @tanstack/react-table from 9.2.4 to 9.3.1
    • @tanstack/react-virtual from 3.14.13 to 3.14.18

    No API change. TruncatedText keeps its contract under the new Radix tooltip: the root still carries no aria-describedby for the tooltip, so a screen reader reads the value once. A consumer's own aria-describedby still lands on the root.

  • #1555 5b0389b Thanks @cody-dot-js! - Sidebar rows now move with the rail. A Sidebar.ItemButton, a Sidebar.SearchTrigger, a Sidebar.SwitcherTrigger, a Sidebar.Separator, and Sidebar.Body's trailing padding used to take their collapsed or expanded shape on the first frame of a toggle, so the current row snapped to full width while the rail was still 52px wide and then grew with it. Each now transitions its width, padding, and margin on the rail's own 200ms ease-out clock, so the row and the rail grow and shrink together, and a second toggle mid-flight reverses from where the first one got to. A collapsing row clips its label with no ellipsis, so the narrowing shows no shimmer of dots. The rail's own width transition moves from ease-linear to the same curve. Sidebar.GroupLabel's fade moves from ease-linear to the same curve. That curve is a new theme token, --ease-out-strong (cubic-bezier(0.23, 1, 0.32, 1)), a strong ease-out for UI that enters, exits, or changes shape, also available as the ease-out-strong utility. The transitions honor motion-reduce: and hold until one frame after hydration, the way the rail's width transition does, and a merged transition-none or duration-* replaces them through tailwind-merge.

    A Sidebar.ItemButton or a Sidebar.SwitcherTrigger composed as a DropdownMenu.Trigger or a Dialog.Trigger inside Sidebar.Tooltip now keeps its open highlight. The tooltip trigger's own data-state replaces the menu trigger's on the row, so the highlight also keys on aria-expanded, which the menu trigger stamps and the tooltip trigger does not.

    useSidebar()'s setOpen and setOpenMobile now keep one identity across renders, the same contract useSidePanel()'s setOpen has, so either is safe in an effect dependency list beside an inline onOpenChange or onOpenMobileChange.

0.88.0

Minor Changes

  • #1527 6f6a27b Thanks @cody-dot-js! - Breaking: Tabs.List draws no border. The hideBorder prop and the data-hide-border attribute are gone, and so is the side border of a vertical classic list. The new Tabs.Separator part draws the hairline instead. Compose it directly after Tabs.List:

    <Tabs.Root defaultValue="account">	<Tabs.List>		<Tabs.Trigger value="account">Account</Tabs.Trigger>		<Tabs.Trigger value="password">Password</Tabs.Trigger>	</Tabs.List>	<Tabs.Separator />	<Tabs.Content value="account">…</Tabs.Content>	<Tabs.Content value="password">…</Tabs.Content></Tabs.Root>

    Why: a horizontal list is a scroll container, and it carries negative margins and padding so the focus ring has room. A border painted on the list itself had to live inside that padding, so it stopped short of the root's edges and faded with the triggers under the scroll mask. The separator is a sibling of the list, so it spans the root's full width, never scrolls, and never fades.

    A horizontal Tabs.List now stretches across its own focus-ring margins instead of taking the root's width. Its triggers reach the root's right edge and line up with the separator; they stopped 8px short before.

    Tabs.Separator composes the mantle Separator. It paints the separator color token, follows the root's orientation (under the list when horizontal, beside the list when vertical), is decorative by default (role="none"), and accepts semantic and asChild. It stamps data-slot="tabs-separator", data-orientation, and data-separator. As the root's direct child it pulls itself up by the new --tabs-gap CSS variable and sits flush against the list while the content keeps the gap. Inside a wrapper of your own, the offset stays off.

    Tabs.Root now sets --tabs-gap (default 1rem) and reads it for its gap. Set the variable, not a gap-* class, to change the space between the list and the content. A gap-* class changes the gap alone and leaves the separator's offset at 1rem.

    Migrate:

    • Add <Tabs.Separator /> after every classic Tabs.List that relied on the default border. A pill list never drew one.
    • Delete hideBorder from every Tabs.List. A list that had hideBorder needs no separator.
    • Replace a [data-hide-border] selector with a [data-slot~="tabs-separator"] selector, or with its absence.
    • On a Tabs.Root that holds a Tabs.Separator as a direct child, replace a gap-* class with [--tabs-gap:…].

    Migration guide: https://mantle.ngrok.com/migrations/0008-tabs-separator-migration

    API reference: https://mantle.ngrok.com/components/navigation/tabs#tabsseparator

Patch Changes

  • #1538 1720c3c Thanks @cody-dot-js! - DescriptionList.Root sizes its value column minmax(0, 1fr) instead of 1fr. A bare 1fr track has an auto minimum that resolves to the value's min-content width, so a long single-line value (a URL, an ID, a token) widened the column and the list scrolled sideways. An ellipsis never appeared, and each call site patched the column with min-w-0 on DescriptionList.Value.

    The column now shrinks to the room left beside the label. A truncate child clips with an ellipsis, and wrap-anywhere or break-all on the value or on a child wraps it, with no min-w-0 on DescriptionList.Value. Remove a min-w-0 you set on DescriptionList.Value for this reason; it is now redundant.

    Content that cannot shrink and has no scroll container of its own (a wide table, a pre) now overflows its cell past the row stripe. DescriptionList.Root still scrolls to reveal it. Give that content overflow-x-auto to scroll it inside the row instead.

    Docs: https://mantle.ngrok.com/components/data-display/description-list#long-values

  • #1540 9016164 Thanks @cody-dot-js! - Bump react-day-picker from 10.0.1 to 10.0.2. Calendar picks up its fixes with no API change.

  • #1531 ad96b54 Thanks @cody-dot-js! - Sidebar.Nav now stamps data-hydrated one frame after hydration instead of in the hydration commit. A persisted-collapsed state that a controlled open applies after hydration (the useLocalStorage recipe) used to commit in the same flush as the stamp, so the collapse animated shut on page load. It now snaps into place.

    Sidebar.Nav and Sidebar.GroupLabel key their hydration gates off data-hydrated in CSS instead of toggling classes in JS, with one negated variant per gate: max-{breakpoint}:not-data-hydrated:hidden for the pre-hydration visibility gate, not-data-hydrated:transition-none for the panel's width transition, and group-not-data-hydrated/sidebar-nav:transition-none for the label's fade. The gates no longer override consumer utilities: a className display utility holds above mobileBreakpoint before hydration as well as after, and a consumer transition-none or duration-* now replaces the default transition through tailwind-merge.

    Sidebar.Nav also owns data-state and data-hydrated: a value passed through props no longer overrides either.

    useIsHydratedAfterPaint is a new hook in @ngrok/mantle/hooks. It returns false on the server, in the hydration render, and in the mount commit, then true from the next animation frame. Use it to gate a CSS transition that must not run on a post-hydration state correction, where useIsHydrated flips too early. Sidebar.Nav stamps data-hydrated from it.

  • #1527 6f6a27b Thanks @cody-dot-js! - Every Tabs part now joins an incoming data-slot chain instead of replacing it, so a part rendered through an asChild ancestor keeps its own slot beside the ancestor's: data-slot="app-layout-body tabs". Before, the ancestor's value won and the part's slot disappeared.

    Tabs.Badge now accepts ref.

    Every Tabs part documents the data-* attributes it stamps, including the data-orientation, data-state, and data-disabled values Radix sets, in both the JSDoc and the API reference: https://mantle.ngrok.com/components/navigation/tabs#api-reference

0.87.0

Minor Changes

  • #1526 9ed4e53 Thanks @cody-dot-js! - AppLayout.Body renders mantle's Main landmark by default: <main id="main" tabIndex={-1}> with data-slot="app-layout-body main". A skip link's default target now lands on the shell's only scroll container with no extra composition, so arrows, Space, and PageDown scroll the page as soon as the jump lands. id and tabIndex are no longer props on AppLayout.Body, for the same reason they are not props on Main.

    This is a breaking change for a shell embedded in a page that already owns a main landmark (a docs demo, a test, a widget inside another layout). There the body now renders a second main with a duplicate id. Migrate:

    • In a shell that owns the document, replace <AppLayout.Body asChild><Main>…</Main></AppLayout.Body> with <AppLayout.Body>…</AppLayout.Body>. The old form still renders one main, but the inner Main is redundant.
    • In an embedded shell, replace <AppLayout.Body> with <AppLayout.Body asChild><div>…</div></AppLayout.Body>. The part adds its classes and data-slot="app-layout-body" to your element and no landmark.
    • Replace a selector that matched [data-slot="app-layout-body"] exactly with [data-slot~="app-layout-body"], because the default body's data-slot now ends in main.

    AppLayout.Header gains three slots that carry the row's flex, so no call site sets flex-1, min-w-0, shrink-0, or ml-auto itself:

    • AppLayout.HeaderStart (data-slot="app-layout-header-start") is shrink-0: the home of Sidebar.Trigger, at the row's start.
    • AppLayout.HeaderContent (data-slot="app-layout-header-content") is min-w-0 flex-1: the home of the Breadcrumb trail. It fills the row and gives width back first, so a long trail scrolls inside it instead of pushing the actions off the card.
    • AppLayout.HeaderActions (data-slot="app-layout-header-actions") is ml-auto shrink-0: the page's actions, at the row's end. It takes only the width its children need and grows to the left as actions are added.

    Each slot is a flex row with the header's own gap-2, accepts asChild, and appends its own data-slot after any you forward. A header composed without the slots renders as before.

    Every page in an app that renders AppLayout renders one AppLayout.Header, with Sidebar.Trigger in AppLayout.HeaderStart and the route's breadcrumb trail in AppLayout.HeaderContent. The App Layout docs state that requirement and show the canonical shell: the shell renders AppLayout.Content around its <Outlet />, and each route renders AppLayout.Header and AppLayout.Body itself, so an action reads the page's own state. The docs compose the three slots in the app shell, standalone, editor, and landmark examples. A shell-owned header whose actions come from React Router route handles is the documented deviation: https://mantle.ngrok.com/recipes/header-actions-from-routes

    Migration guide: https://mantle.ngrok.com/migrations/0007-app-layout-header-slots-migration

    API reference: https://mantle.ngrok.com/layouts/app-layout#applayoutbody

Patch Changes

  • #1524 4854ba3 Thanks @cody-dot-js! - Sheet parts now read a private Radix dialog scope, so each one binds to the nearest Sheet.Root and to nothing else. A Command.SearchTrigger, Command.DialogTrigger, Dialog.Trigger, or Dialog.Close rendered inside Sheet.Content (a Sidebar.Nav below its mobileBreakpoint, say) now reaches the Dialog.Root or Command.DialogRoot above the sheet instead of toggling the sheet. Before this fix, a click on the search row in the mobile sidebar closed the sheet and never opened the palette.

    Two undocumented shapes now fail fast instead of controlling the wrong overlay:

    • A Dialog.Trigger or Dialog.Close inside a sheet with no Dialog.Root above it throws Radix's "must be used within Dialog". Use Sheet.Trigger and Sheet.Close to control a sheet.
    • A Sheet.Trigger, Sheet.Close, or Sheet.CloseIconButton inside a Dialog.Content with no Sheet.Root above it throws the same error. Use Dialog.Close there.

    The Sheet docs state the binding rule: https://mantle.ngrok.com/components/overlays/sheet#sheetroot

    The Command docs now put Command.DialogRoot above Sidebar.Nav, where ⌘K outlives the mobile sheet and the search row inside the sheet still opens the palette: https://mantle.ngrok.com/components/navigation/command#in-a-sidebar

0.86.3

Patch Changes

  • #1521 a48d20b Thanks @cody-dot-js! - Combobox and MultiSelect import ariakit from @ariakit/react/combobox and @ariakit/react/store instead of the @ariakit/react barrel. The barrel re-exports every ariakit component, so one Combobox import evaluated tabs, menus, dialogs, and the rest. Every other component that wraps a Radix or sonner primitive now imports it by name as well, so no mantle module holds a namespace import of a primitive. No public API changes.

    A production bundle does not change, because @ariakit/react and @ariakit/react-components both set sideEffects: false and a bundler already drops the unused chunks. A dev server evaluates the module graph on every SSR request, and a test runner that isolates each file in a fresh process evaluates it once per file. Both see the saving. The table reports a fresh Node 24 process per import with the ESM import condition, five rounds, and the median wall time:

    Entry pointModules beforeModules afterImport time beforeImport time after
    @ngrok/mantle/combobox293135211 ms87 ms
    @ngrok/mantle/multi-select312157218 ms96 ms
    @ngrok/mantle/dialog878732 ms32 ms
    @ngrok/mantle/select979737 ms37 ms
    @ngrok/mantle/toast343416 ms16 ms
    @ngrok/mantle/button282817 ms16 ms

    The Radix and sonner rows do not move, because each of those packages already ships one component per package. Those imports change for consistency only.

0.86.2

Patch Changes

  • #1511 e26dc3b Thanks @randseay! - Table.Header changes from px-4 to px-3, so the column label aligns with the p-3 body text in Table.Cell. Body cells do not move and the row height does not change. A header that sets its own horizontal padding keeps it, because a consumer className wins through tailwind-merge.

0.86.1

Patch Changes

  • #1512 0ca0fbd Thanks @cody-dot-js! - Command.DialogRoot and Sidebar.Root no longer throw on a keydown that carries no key.

    Both bind a window listener for their chord. The listener reads event.key.toLowerCase(). The DOM type promises a string, so the read looked safe. A browser extension or a password manager can dispatch a bare Event("keydown") instead of a real KeyboardEvent. The listener then threw Cannot read properties of undefined (reading 'toLowerCase') on the page. Both listeners now read the key optionally. They ignore an event that names no key.

0.86.0

Minor Changes

  • #1505 748773c Thanks @cody-dot-js! - Select.Content no longer accepts asChild. The prop is omitted from its props type.

    The content renders the scroll buttons and the viewport around your items, so there is no single child for a slot to clone. asChild hands Slot every child, and Slot takes exactly one, so the prop threw Primitive.div failed to slot onto its children at render. The docs page has carried that warning since the part shipped. The type error now names the problem at compile time instead.

    Migration. A call site that passes asChild to Select.Content crashes today, so nothing working breaks. Style the part with className instead.

    Select.Trigger, Select.Item, Select.Group, Select.Label, and Select.Separator keep asChild.

Patch Changes

  • #1505 748773c Thanks @cody-dot-js! - AlertCenter.Item no longer crashes on a page a browser translation engine has translated. The item portals its children into a per-id host element, and a portal owns no node of its own, so React removed each of those children from the host one at a time. When a child was a bare string, Google Translate had already reparented that text node into a <font> wrapper, and the removeChild raised NotFoundError. React re-threw the raw DOMException, which tore down the React root and blanked the page. Dismissing the alert and navigating away from the route both took that path, and both are the documented way to remove an item.

    children now render inside a <div data-slot="alert-center-item-label">, which is the only node the portal puts in the host. React removes that element instead of a text node, and an element removal cannot throw. A lone string child also moves onto React's textContent path, which wipes the translation wrapper, so a swap between text and an element inside the item is safe too.

    The wrapper is display: contents, so it adds no box: Alert.Icon and Alert.Content stay direct flex items of the banner chrome and keep its gap. The bar's exit ghost now reads the wrapper's children rather than the host's, so an item whose children render nothing still captures no ghost. alert-center-item-label is public API, and the docs page lists it beside alert-center-item-host.

  • #1505 748773c Thanks @cody-dot-js! - routeBreadcrumb.content now documents what a content crumb must render: a host element at its root, never bare text. Breadcrumb.List renders an <ol>, so the crumb's outermost node is a direct child of a list. A bare string there is a text node React removes by itself, and a browser translation engine has reparented it first, so the removal throws and the page goes blank. No wrapper can fix it from mantle's side, because a Breadcrumb.Item wrapper would nest an <li> in an <li>.

    The JSDoc on the content factory and on the Crumb union both carry the contract, and the breadcrumbs recipe states it above every content-crumb example. The type still takes ReactNode; narrowing it to ReactElement is a breaking change and waits for the next minor.

  • #1508 fc71cca Thanks @cody-dot-js! - Bump runtime dependencies: @ariakit/react to 0.4.40 and @tanstack/react-virtual to 3.14.13.

    The @ariakit/react bump reaches Combobox and MultiSelect, whose items build on ariakit's Command. Version 0.4.40 fixes Command keyboard activation with Enter and Space in Vitest's default jsdom environment, so a consumer test that runs in jsdom and presses Enter or Space on an item now selects it. The fix only reaches jsdom.

    The @tanstack/react-virtual bump reaches List.VirtualRoot and SelectableList.VirtualViewport, which compose the virtualizer's measureElement onto each row as a ref callback. The virtualizer no longer calls flushSync from that callback, so React's development build no longer logs the flushSync warning when a row measures. The bump also keeps a row's key in the measurement cache, so a getItemKey callback that reads mutable data cannot change the identity of a measured row, and the debounced scroll-end fallback now reads the current scroll offset instead of overwriting a measurement adjustment with stale state.

  • #1505 748773c Thanks @cody-dot-js! - AreaChart, BarChart, LineChart, and ScatterPlot no longer crash their Tooltip and Legend render props on a page a browser translation engine has translated. Both host elements stay mounted for the life of the chart, so React removed a render prop's text nodes one at a time whenever the content went away: the pointer left the plot, the chart lost focus, Escape dismissed the readout, or keyboard stepping cleared the point cursor. Google Translate reparents each of those text nodes into a <font> first, and the removeChild threw NotFoundError, which tore down the React root and blanked the page. A readout that returned a fragment of bare text, such as <>{hover.xValue}: {value}</>, hit it on the first mouse-out.

    A children readout now renders inside a <div data-slot="area-chart-tooltip-label">, and a children legend inside <div data-slot="area-chart-legend-label">. The other three charts spell them bar-chart-, line-chart-, and scatter-plot-. React removes that element instead of the text nodes, and an element removal cannot throw. Each div is display: contents, so it adds no box: your content stays a direct layout child of the tooltip or the legend, and a className you pass to either part keeps laying it out. The default readout and the default legend are unchanged and render no such div.

    The eight slots are public API. The JSDoc and the docs-page API reference for each part list them.

  • #1505 748773c Thanks @cody-dot-js! - The @example on useCommandDialog no longer teaches a shape that crashes a translated page. It rendered Search everything for “{query}” as three children of a Command.Item, and Command.DialogRoot resets the query on every open, so React removed the middle text node between two literal siblings. A browser translation engine reparents that node first, so the removal threw NotFoundError and blanked the page. The example is one template literal now, which makes the interpolation a lone child React writes through textContent.

  • #1505 748773c Thanks @cody-dot-js! - DataTable.ActionCell and DataTable.ActionHeader now render children inside a label span: data-table-action-cell-label and data-table-action-header-label. Both cells hold a sticky-column indicator beside children, so a bare text child was never a lone child. On a page a browser translation engine had translated, the removal React runs when the content changes shape or goes away raised NotFoundError and blanked the page. A placeholder cell that gains an action button, and an ActionHeader whose children fall back to the built-in sr-only label, both took that path.

    Moving the indicator after children cured its own mount and nothing else, so the 2026-08-04 guarantee for ActionHeader was narrower than the docs stated. The label span covers the removal and the type swap the move never reached.

    Both spans are display: contents, so no layout moves. The two slots are public API, and the docs page lists each beside its cell.

  • #1505 748773c Thanks @cody-dot-js! - DropdownMenu.SubTrigger, DropdownMenu.CheckboxItem, and DropdownMenu.RadioItem now render children inside a label span: dropdown-menu-sub-trigger-label, dropdown-menu-checkbox-item-label, and dropdown-menu-radio-item-label. Each part renders a caret or a check indicator that never unmounts, so a bare text label was never a lone child. On a page a browser translation engine had translated, the removal React runs when the label changes shape or goes away raised NotFoundError and blanked the page. Each span stays mounted, so a lone string label takes React's textContent path, which wipes the translation wrapper instead of removing a node the item no longer owns. Each span is display: contents, so an icon and its label stay flex items of the item and keep its gap.

    Migration. An icon you pass as a child of one of these three parts is now a grandchild, so a [&>svg] class on the item no longer reaches it. Each part's own default moved with it, to [&>[data-slot=<part>-label]>svg]:size-5. Change a [&>svg]:… override of your own to the matching [&>[data-slot=<part>-label]>svg]:…. That form shares the default's variant prefix, so tailwind-merge drops the default instead of shipping both. A [&_svg]:… class, or a class on the icon itself, is not enough: it also loses on specificity to the item's extra attribute selector. DropdownMenu.Item renders no sibling and no wrapper, so its [&>svg] default is unchanged.

    The three slots are public API, and the docs page lists each beside its part.

  • #1505 748773c Thanks @cody-dot-js! - MultiSelect.Item now renders children inside a <div data-slot="multi-select-item-label">. The selected-state check is a permanent element sibling, so a bare text label was never a lone child: on a page a browser translation engine had translated, the removal React runs when the label changes shape or goes away raised NotFoundError and blanked the page. The wrapper stays mounted, so a lone string label takes React's textContent path, which wipes the translation wrapper instead of removing a node the option no longer owns.

    The wrapper is a <div> because a custom option layout is often a MediaObject, whose root is a <div> that a <span> may not contain. It is display: contents, so a child of your own stays a flex item of the option and any flex-1 on it still resolves.

    multi-select-item-label is public API, and the docs page lists it beside multi-select-item.

  • #1509 171c72d Thanks @cody-dot-js! - asChild now works on DropdownMenu.SubTrigger, DropdownMenu.CheckboxItem, DropdownMenu.RadioItem, Select.Trigger, Select.Item, and MultiSelect.Item.

    Each of the six renders a second element beside your children: a submenu caret, or a check indicator. The prop threw at render on every one of them, because the slot received two children and takes exactly one. Each part now clones your single child as its element and moves the label span and that second element inside it. Your child keeps its own props, and the part's class names, data attributes, and ref merge onto it.

    <DropdownMenu.RadioItem value="small" asChild>	<Link to="?size=small">Small</Link></DropdownMenu.RadioItem>

    The label wrapper each part carries for translated pages rides along, so a bare string inside your child stays safe on both paths.

  • #1505 748773c Thanks @cody-dot-js! - Select.Trigger now renders children inside a <span data-slot="select-trigger-label">. The caret is a permanent element sibling, so a bare text child was never a lone child: on a page a browser translation engine had translated, the removal React runs when that child changes shape or goes away raised NotFoundError and blanked the page. A trigger that renders selected ? <span>{selected.label}</span> : "Select" took that path on the first pick. The span stays mounted, so a lone string child takes React's textContent path, which wipes the translation wrapper instead of removing a node the trigger no longer owns.

    The span is display: contents, so Select.Value stays a flex item of the trigger and keeps its gap.

    Migration. Select.Value is now a grandchild of the trigger, so a [&>span] class on the trigger reaches the wrapper instead. The part's own defaults moved with it, from [&>span]:line-clamp-1 [&>span]:text-left to [&>[data-slot=select-trigger-label]>span]:line-clamp-1 and [&>[data-slot=select-trigger-label]>span]:text-left. Change a [&>span]:… override of your own to the matching [&>[data-slot=select-trigger-label]>span]:…. That form shares the default's variant prefix, so tailwind-merge drops the default instead of shipping both. An unchanged [&>span]:line-clamp-none now lands on the wrapper: line-clamp sets display, which gives the wrapper a box and takes Select.Value out of the trigger's flex row.

    select-trigger-label is public API, and the docs page lists it beside select-trigger.

  • #1505 748773c Thanks @cody-dot-js! - SelectableList.Empty no longer crashes on a page a browser translation engine has translated. The part is a role="status" live region that stays mounted, so React removes the message from inside it when the filter matches options again. A message built from more than one node, such as No results for <strong>{query}</strong>., puts real text nodes there. Google Translate reparents each one first, and the removeChild threw NotFoundError, which tore down the React root and blanked the page. One keystroke in each direction was enough to hit it.

    The message now renders inside a <span data-slot="selectable-list-empty-label">. React removes that element instead of the text nodes, and an element removal cannot throw. The span is display: contents, so it adds no box: the message stays a direct child of the centered region, and the layout is unchanged. A message that is a lone string also takes React's textContent path now, which wipes the translation wrapper instead of fighting it.

    The new slot is public API. The JSDoc and the docs-page API reference list it beside selectable-list-empty.

  • #1505 748773c Thanks @cody-dot-js! - SelectableList.SelectAll now renders children inside a <span data-slot="selectable-list-select-all-label">. The checkbox is a permanent element sibling, so a bare text label was never a lone child: on a page a browser translation engine had translated, the removal React runs when a count-aware label swaps to an element raised NotFoundError and blanked the page.

    The span is display: contents, so the checkbox and the label stay flex items of the header and keep its gap.

    selectable-list-select-all-label is public API, and the docs page lists it beside selectable-list-select-all.

  • #1505 748773c Thanks @cody-dot-js! - Sidebar.Trigger, Sidebar.Tooltip, and Sidebar.SearchTrigger no longer crash on a page a browser translation engine has translated. Both defects were the same shape: a shortcut crossing null changed the rendered node type, so React deleted the whole child list instead of appending. The translated label was reparented by then, and that removal raised NotFoundError, which tore down the React root and blanked the page.

    The shared tooltip body always renders its <span data-slot="sidebar-tooltip-row"> now, and only the chord chips are conditional, so a shortcut that resolves after the first paint appends beside an element. Sidebar.SearchTrigger branches on asChild rather than on shortcut, and its <span data-slot="sidebar-search-trigger-shortcut"> is always mounted on the default button path. That span is display: none while it is empty, so a row with no chord keeps its layout and its flex gap exactly as before. Under asChild the span is absent, which keeps Slot at exactly one child.

    Each part also wraps the content a consumer passes, because a mounted chord span leaves a bare text label with a sibling and crashes the consumer's own label swap. The tooltip body wraps label in a <span data-slot="sidebar-tooltip-label">, and Sidebar.SearchTrigger wraps children in a <span data-slot="sidebar-search-trigger-label"> on the default button path. Both are display: contents, so neither adds a box. The chord chips carry sidebar-tooltip-shortcut and sidebar-search-trigger-shortcut. All five slots are public API, and the docs page lists each beside its part.

    Migration. The leading icon in a Sidebar.SearchTrigger is now a grandchild on the default button path, so the part's own [&>svg:first-child] utilities gained a [&>[data-slot=sidebar-search-trigger-label]>svg:first-child] twin. Both forms ship, so a direct leading icon on the asChild path keeps its size and its colors exactly as before. Add the matching slot-scoped form to a [&>svg]:… override of your own, or it stops reaching the icon on the button path.

  • #1505 748773c Thanks @cody-dot-js! - Tabs.Trigger now renders children inside a <span data-slot="tabs-trigger-label">, on the plain path and the asChild path alike. The active-tab decoration is a permanent element sibling, so a bare text label was never a lone child: on a page a browser translation engine had translated, the removal React runs when the label changes shape or goes away raised NotFoundError and blanked the page. The span stays mounted, so a lone string label takes React's textContent path, which wipes the translation wrapper instead of removing a node the trigger no longer owns. The span is display: contents, so the icon, the label, and Tabs.Badge stay flex items of the trigger and keep its gap.

    Migration. An icon you pass as a child of a trigger is now a grandchild, so a [&>svg] class on the trigger no longer reaches it. The part's own default moved with it, from [&>svg]:size-5 to [&>[data-slot=tabs-trigger-label]>svg]:size-5. Change a [&>svg]:… override of your own to the matching [&>[data-slot=tabs-trigger-label]>svg]:…. That form shares the default's variant prefix, so tailwind-merge drops the default instead of shipping both. A [&_svg]:… class, or a class on the icon itself, is not enough: it also loses on specificity to the trigger's extra attribute selector. An unchanged [&>svg]:… override now matches nothing, and the default survives beside it.

    tabs-trigger-label is public API, and the docs page lists it beside tabs-trigger.

  • #1505 748773c Thanks @cody-dot-js! - Toast.Root now renders children inside a <div data-slot="toast-label">. The intent accent bar is a permanent element sibling, so a bare text child was never a lone child: on a page a browser translation engine had translated, the removal React runs when the content changes shape or goes away raised NotFoundError and blanked the page. A toast that makeToast updates in place under a repeated id took that path.

    The wrapper is a <div> because Toast.Message renders a <p>, which a <span> may not contain. It is display: contents, so the icon, the message, and an action stay flex items of the root and Toast.Message's flex-1 still resolves.

    toast-label is public API, and the docs page lists it beside toast. It wraps everything you pass the root, not only the message text.

  • #1505 748773c Thanks @cody-dot-js! - Tooltip.Content now renders children inside a <div data-slot="tooltip-label">. The arrow is a permanent element sibling, so a bare text body was never a lone child of the surface: React held a real text node for it, and on a page a browser translation engine had translated, the removal React runs when the body changes shape or goes away raised NotFoundError and blanked the page. The wrapper stays mounted, so a lone string body takes React's textContent path, which wipes the translation wrapper instead of removing a node the surface no longer owns.

    The wrapper is a <div> because a tooltip body is often a <p>, which a <span> may not contain. It is display: contents, so it adds no box and every child of your body stays a layout child of the surface.

    tooltip-label is public API, and the docs page lists it beside tooltip-content. TooltipProvider no longer stamps data-slot="tooltip-provider": the Radix provider renders no DOM, so that attribute named an element that never existed.

0.85.2

Patch Changes

  • #1501 b4f2d00 Thanks @cody-dot-js! - Every Select part now joins a data-slot you pass ahead of its own slot instead of replacing it, so <Select.Trigger data-slot="app-trigger"> renders data-slot="app-trigger select-trigger" and both hooks keep working.

    The docs page and the JSDoc now list the data attributes each part stamps: data-state, data-placeholder, data-disabled, and data-validation on Select.Trigger; data-state, data-side, and data-align on Select.Content; and data-state, data-highlighted, and data-disabled on Select.Item. Every Select.Root prop carries a description.

    Select.Content no longer animates when the user prefers reduced motion.

  • #1501 b4f2d00 Thanks @cody-dot-js! - Select no longer crashes on a page a browser translation engine has translated. Radix portals the selected item's text into Select.Value and keys the placeholder, so React removed a bare text node from the trigger whenever the value changed, the placeholder gave way to a first value, or the select unmounted on navigation. Google Translate reparents that text node first, and the removeChild threw NotFoundError, which tore down the React root and blanked the page.

    Select.Item now renders children inside a <span data-slot="select-item-label">. Select.Value renders placeholder inside a <span data-slot="select-placeholder"> and your own children inside a <span data-slot="select-value-label">. React removes those elements instead of a text node, and an element removal cannot throw. Each span is display: contents, so it adds no box: your own item or value content stays the direct layout child of the value node, and a trigger override such as [&>span]:flex-1 keeps working. A lone string child on Select.Value also takes React's textContent path now, which wipes the translation wrapper, so a swap between text and an element there is safe too.

    Select.Value also carries data-slot="select-value". All four slots are public API, and the data-attribute tables on the docs page list them. You no longer need to wrap item labels in your own <span>.

    translate="no" on a Select.Item now also rides on the label span, so the copy the trigger shows stays untranslated. Set it on an item whose label is an ID, a path, a filename, or a key.

0.85.1

Patch Changes

  • #1447 886cb87 Thanks @cody-dot-js! - Ship React Compiler output in the published package. Components and hooks come precompiled with automatic memoization, so consumers get the render-performance win without running the compiler themselves. React 19, which the package already requires, carries the react/compiler-runtime import the compiled code uses. The build runs the compiler through oxc-transform-react, the Rust port of the React Compiler.

    DataTable compiles too. TanStack Table v9 keeps its row, column, and header objects stable, so the parts that read state through them (Header, Row, HeaderSortButton, RowExpandButton, ExpandedRow) now read it inside a TanStack Subscribe boundary, as TanStack's React Compiler guide describes. Each boundary also re-runs when useTable receives new options. DataTable.Row subscribes to the whole table state, so a column's cell renderer can keep reading row.getIsSelected() and similar methods, and can keep closing over your component's values. List.VirtualRoot reads useVirtualizer through an opted-out hook and compiles otherwise.

    Treat props as immutable. A compiled component memoizes on prop identity, so an object or array mutated in place after you passed it does not re-render the component. This was already the React contract; the compiler now relies on it.

    Measured in headless Chromium against a tree of 12 cards with an 8-row table each (288 leaf elements), median time per update, production React:

    ScenarioUncompiled mantleCompiled mantle
    Parent re-render, mantle props unchanged, app uncompiled1.0 ms0.8 ms
    Parent re-render, mantle props unchanged, app compiled1.0 ms<0.1 ms
    One leaf label changes1.0 ms0.8 ms
    Mount, app uncompiled2.9 ms3.2 ms
    Mount, app compiled2.1 ms2.1 ms

0.85.0

Minor Changes

  • #1492 82d2c3b Thanks @cody-dot-js! - Move DataTable to TanStack Table v9 (@tanstack/react-table 9.2.4). v9 stores table state in TanStack Store atoms, registers features per table so unused ones tree-shake away, and works with the React Compiler. @ngrok/mantle/data-table still re-exports everything from @tanstack/react-table, so no app needs a direct dependency on it. Follow the migration guide: https://mantle.ngrok.com/migrations/data-table-tanstack-v9-migration

    Breaking: the table instance you build changes. Every DataTable part keeps its name and props, but the TanStack calls around it move to the v9 API:

    // beforeimport {	DataTable,	createColumnHelper,	getCoreRowModel,	getSortedRowModel,	useReactTable,} from "@ngrok/mantle/data-table"; const columnHelper = createColumnHelper<Payment>();const columns = [columnHelper.accessor("email", {/* … */})];const table = useReactTable({	data,	columns,	getCoreRowModel: getCoreRowModel(),	getSortedRowModel: getSortedRowModel(),});const sorting = table.getState().sorting; // afterimport {	DataTable,	createColumnHelper,	createSortedRowModel,	rowSortingFeature,	sortFn_alphanumeric,	sortFn_datetime,	sortFn_text,	tableFeatures,	useTable,} from "@ngrok/mantle/data-table"; const features = tableFeatures({	rowSortingFeature,	sortedRowModel: createSortedRowModel(),	sortFns: {		alphanumeric: sortFn_alphanumeric,		datetime: sortFn_datetime,		text: sortFn_text,	},});const columnHelper = createColumnHelper<typeof features, Payment>();const columns = columnHelper.columns([columnHelper.accessor("email", {/* … */})]);const table = useTable({ features, data, columns });const sorting = table.state.sorting;
    • useReactTable is now useTable, and its options require features from tableFeatures({ … }).
    • The get*RowModel() options are gone. The core row model is automatic. Register the rest as feature and slot pairs: rowSortingFeature + sortedRowModel: createSortedRowModel(), columnFilteringFeature (+ globalFilteringFeature) + filteredRowModel: createFilteredRowModel(), rowPaginationFeature + paginatedRowModel: createPaginatedRowModel(), rowExpandingFeature + expandedRowModel: createExpandedRowModel(), columnGroupingFeature + groupedRowModel: createGroupedRowModel(), rowSelectionFeature, columnVisibilityFeature.
    • createColumnHelper<TData>() is now createColumnHelper<typeof features, TData>(), and the column array must go through columnHelper.columns([ … ]). A bare array no longer typechecks against the columns option.
    • table.getState() is now the table.state property.
    • sortingFn is now sortFn, and a string name resolves only against the sortFns slot. Register sortFn_alphanumeric, sortFn_datetime, and sortFn_text there to keep v8 auto-sort behavior; without a slot, auto-sort falls back to sortFn_basic. The exported sortFns and filterFns registries are deprecated in v9, so import the functions you use.
    • A column's filterFn: "auto" default and every string name resolve only against the filterFns slot. A grouped total with aggregationFn: "auto" or a string name resolves only against aggregationFns, and it needs rowAggregationFeature registered. A function value bypasses both slots. Register includesString: filterFn_includesString to keep v8 column filtering on string columns, and sum: aggregationFn_sum to keep v8 totals on group rows. Without them, a column filter returns every row and a group row's getValue() is undefined; only a development build warns.
    • The instance types gain a leading TFeatures parameter: Column<typeof features, TData, TValue>, Row<typeof features, TData>, Table<typeof features, TData>, ColumnDef, HeaderContext, CellContext, and TableOptions. State types such as SortingState and ExpandedState are unchanged.

    What the mantle parts now require. DataTable.HeaderSortButton accepts a column only from a table that registers rowSortingFeature, and DataTable.RowExpandButton accepts a row only from a table that registers rowExpandingFeature. Both are compile-time errors on that prop. DataTable.Row, DataTable.EmptyRow, and DataTable.ExpandedRow work with or without columnVisibilityFeature: with it they respect hidden columns, without it they render every column. DataTable.Header sets aria-sort only when the table can sort.

    Not supported. mantle does not re-export the deprecated @tanstack/react-table/legacy shim (useLegacyTable, legacyCreateColumnHelper, the get*RowModel stubs). Move each table to useTable instead.

    Performance. Measured against main (v8) with a 6-column sortable table built from the DataTable parts, in headless Chromium on production React 19.2.8, medians of 3 rounds:

    RowsMountParent re-render, same propsSort toggleNew data array, same lengthRetained heap after mount
    1,00033.6 → 31.1 ms (−7%)8.2 → 7.4 ms (−10%)14.5 → 11.7 ms (−19%)14.3 → 11.9 ms (−17%)9.8 → 7.4 MiB (−24%)
    10,000378.6 → 344.1 ms (−9%)78.4 → 73.6 ms (−6%)263.5 → 238.9 ms (−9%)143.3 → 121.4 ms (−15%)105.8 → 82.0 MiB (−23%)

    A consumer bundle that registers only rowSortingFeature is 0.8 kB gzip smaller than its v8 equivalent (35.4 → 34.6 kB). A bundle that registers every stock feature is 5.5 kB larger (36.8 → 42.3 kB), because v9 features carry code that v8 shipped inside core. Register only the features a table uses.

Patch Changes

  • #1493 4ae38d3 Thanks @cody-dot-js! - Table.Root no longer forces a layout inside React's commit. Its overflow observer used to read scrollWidth in a layout effect on mount, which laid out the whole table before React could finish the commit. The first read now runs in the ResizeObserver callback, after the browser's own layout, and a flushSync there keeps data-x-overflow, data-x-scroll-end, and data-sticky-active on the first painted frame. On a 1,000-row DataTable the mount commit drops from about 31 ms to 14 ms, and from about 344 ms to 142 ms at 10,000 rows. Time to first paint is unchanged, because the browser lays out the table either way.

0.84.3

Patch Changes

  • #1489 af79e2e Thanks @cody-dot-js! - PasswordInput now animates its eye icon on every visibility change, including a change to showValue from another control. Before this change, only a click on the built-in toggle animated the icon, so a page that reveals several fields from one control swapped the icons with no motion. The icon no longer animates when a controlled consumer ignores the toggle, because the icon did not change. Reduced motion still turns the animation off. The docs page shows a controlled example under Visibility state.

  • #1489 af79e2e Thanks @cody-dot-js! - PasswordInput now documents every data attribute it stamps, in its JSDoc and on its docs page: data-slot="input-capture" on the <input>, and data-disabled and data-validation on the chrome, beside the two data-slot values it already listed. PasswordInputProps gained a JSDoc summary. See the PasswordInput API.

0.84.2

Patch Changes

  • #1478 36aceb1 Thanks @cody-dot-js! - Accordion collapsed content now leaves the accessibility tree and the tab order in every browser, as the docs state. In browsers that support hidden="until-found", nothing changes: the content carries that attribute and find-in-page can still reveal it. In other browsers, and in the server HTML, the collapsed Accordion.Content now carries inert. Before this change those browsers kept the collapsed content, and any buttons or links inside it, focusable and readable while it was clipped to zero height.

    Accordion.Trigger now carries aria-controls, and Accordion.Content carries the matching id, as the WAI-ARIA disclosure pattern requires. Accordion.Item generates the id so the pair matches in the server HTML, and id is no longer a prop on Accordion.Content.

  • #1478 36aceb1 Thanks @cody-dot-js! - AlertDialog.Content now carries role="alertdialog", and a pointer interaction outside the content no longer closes the dialog. The docs already promised that the user must acknowledge an alert dialog; before this change the content had role="dialog" and a click on the backdrop dismissed it. Escape, AlertDialog.Cancel, and AlertDialog.Close still close it. Your onInteractOutside handler still runs; the content calls event.preventDefault() after it.

    The AlertDialog.Icon JSDoc and docs now describe its real props (svg on an <svg> element, no asChild), and every namespace member's JSDoc summary matches its part.

  • #1478 36aceb1 Thanks @cody-dot-js! - Anchor renders rel="noopener noreferrer" when target="_blank" and rel is omitted. The JSDoc and docs page promised this default; the code never set it. A rel you pass is used as-is. The icon prop is now decorative by default (aria-hidden unless the SVG carries a name, a role, or an explicit aria-hidden), as the docs already said, and the docs API table gains the rel row.

  • #1478 36aceb1 Thanks @cody-dot-js! - Make a disabled or loading Button and IconButton inert under asChild. The native disabled attribute does nothing on an <a>, so before this change a disabled asChild link announced aria-disabled="true" but stayed in the tab order and still navigated on click. The child now gets no disabled attribute; it gets aria-disabled="true", tabIndex={-1}, and a click on it is cancelled before its own handler or a router runs. The cancel replaces the child's own onClickCapture while disabled, because Radix runs a child handler before a slot handler. The dimmed styling applies through aria-disabled as well as disabled. Pointer events stay on, so a Tooltip that explains why the button is disabled still opens on hover.

    disabled and isLoading now always win: aria-disabled={false} can no longer re-enable a button the caller disabled.

    The JSDoc and the docs pages now list every data attribute both buttons emit (data-appearance, data-intent, data-size, data-loading, data-disabled, and data-icon-button), name isLoading where they once named a state prop that never existed, and document ButtonGroup.

  • #1478 36aceb1 Thanks @cody-dot-js! - Calendar now merges className with classNames.root instead of letting a classNames.root replace it. Its JSDoc documents the data-slot="calendar" hook and states that it is a month grid with no text entry.

  • #1478 36aceb1 Thanks @cody-dot-js! - Align the Card namespace JSDoc with each part's declaration, so Card.Root, Card.Body, Card.Header, and Card.Title show one summary in every editor surface.

  • #1478 36aceb1 Thanks @cody-dot-js! - Close accessibility and documentation gaps shared by BarChart, LineChart, AreaChart, and ScatterPlot.

    • The sr-only keyboard instructions now name every key the chart handles: Left and Right, Page Up and Page Down, Home and End, Enter or Space, and Escape. They named only Left, Right, Home, End, and Enter before.
    • A decorative chart is now inert, so a composed CopyButton inside the hidden backdrop is no longer a tab stop.
    • CopyButton announces "Copied" through an always-mounted role="status" element after a copy. The icon swap was the only feedback before. A second copy inside the two-second reset window is announced again.
    • The sr-only data table caption now reads the text of the aria-labelledby element, so a chart named by a heading gets a distinct caption instead of "Chart data".
    • BarChart.Grid, BarChart.XAxis, BarChart.YAxis, and BarChart.ReferenceLine JSDoc now state the horizontal-orientation behavior, and the Grid default reads "perpendicular to the bars".
  • #1478 36aceb1 Thanks @cody-dot-js! - Checkbox sets aria-readonly="true" when readOnly is set. The native readonly attribute is inert on a checkbox, so mantle blocks the toggle with a click guard; assistive technology now hears the state instead of a toggle that fails in silence.

  • #1478 36aceb1 Thanks @cody-dot-js! - Choice.Description no longer takes id. Choice.Root owns that id and points the control's aria-describedby at it; a consumer id replaced it and left the reference dangling. The id is now stamped after the props spread, so a wider props object cannot override it either.

  • #1478 36aceb1 Thanks @cody-dot-js! - Close accessibility gaps in CodeBlock.

    • The <pre> no longer carries aria-expanded, which is not valid on a <pre>. It now carries data-state="collapsed" | "expanded" while a CodeBlock.ExpanderButton is composed, and the collapsed max height reads that attribute. CodeBlock.ExpanderButton keeps its own aria-expanded and aria-controls. If you styled the collapsed state with the aria-collapsed: variant on the <pre>, use data-[state=collapsed]: instead.
    • Line numbers are aria-hidden, so a screen reader reads the code, not "1 const x".
    • CodeBlock.CopyButton announces "Copied" through an always-mounted role="status" element after a copy. A second copy inside the two-second reset window is announced again.
    • CodeBlock.ExpanderButton no longer types asChild. The prop threw at runtime, because the button renders its own label and caret.
  • #1478 36aceb1 Thanks @cody-dot-js! - The Combobox.Group, Combobox.GroupLabel, and Combobox.ItemValue summaries in the built types now carry the same wrapping and aria-labelledby guidance as the docs page.

  • #1478 36aceb1 Thanks @cody-dot-js! - Command.Shortcut now locks translate="no", like Kbd, so a browser translator never renames a shortcut key. MetaKey hides its glyph from assistive technology, so a screen reader reads its label ("Command" or "Control") once. The translate prop is removed from its type. The search icon in Command.Input is hidden from assistive technology, and the Command.SearchTrigger, Command.DialogRoot, and Command.DialogContent summaries in the built types now match their docs: SearchTrigger renders no DOM of its own.

  • #1478 36aceb1 Thanks @cody-dot-js! - Fix the DataTable header accessibility contract.

    • DataTable.Header takes an optional column. When the column is sorted, the cell carries aria-sort="ascending" or "descending". Pass the same column you pass DataTable.HeaderSortButton.
    • DataTable.HeaderSortButton keeps the column label as its accessible name. It no longer prepends a screen-reader-only "Column sorted in … order" sentence, so the name does not change on every click and a label query matches the column name alone. The sort state lives in aria-sort on the header cell and data-sort-direction on the button.
    • A column that cannot sort (disableSorting, or enableSorting: false on the column) renders its label as plain text. It was a focusable button whose click did nothing.
    • DataTable.ActionHeader renders a screen-reader-only "Actions" label when you pass no children, so the action column has a name.
    • DataTable.EmptyRow spans the visible leaf columns, not every registered column, so a hidden column no longer stretches the empty state past the table.
    • data-expanded on DataTable.Row is now documented.
  • #1478 36aceb1 Thanks @cody-dot-js! - DescriptionList exports DescriptionListItemProps, DescriptionListLabelProps, and DescriptionListValueProps alongside DescriptionListProps. A data-slot passed to DescriptionList.Root now joins in front of "description-list" instead of replacing it, and every part documents its data-slot. The part summaries in the built types now match their declarations.

  • #1478 36aceb1 Thanks @cody-dot-js! - Dialog.Description JSDoc: the namespace copy now states that the part renders a div and takes asChild, the same as the part declaration.

  • #1478 36aceb1 Thanks @cody-dot-js! - DropdownMenu.Shortcut locks translate="no", so a browser translation engine cannot rename a shortcut key; the prop is omitted from its type. The DropdownMenu.SubTrigger JSDoc and docs now say it opens on hover, ArrowRight, or Enter, not on focus.

  • #1478 36aceb1 Thanks @cody-dot-js! - Empty.Title is typed as ComponentProps<"h3">, so it accepts ref as the docs state. The docs now give Empty.Icon's real size (size-10) and say the icon is decorative by default.

  • #1478 36aceb1 Thanks @cody-dot-js! - HoverCard JSDoc: the Content and Portal namespace summaries now match their parts, including that content mounts into the nearest layer container when container is omitted.

  • #1478 36aceb1 Thanks @cody-dot-js! - Icon and SvgOnly are decorative by default. They now render aria-hidden="true" unless the props or the svg element carry a name (aria-label, aria-labelledby, a <title> child, or Phosphor's alt), a role, or an explicit aria-hidden. A bare Phosphor icon sets none of these, so before this change every icon that sat next to a text label inside an <a>, a <span>, or a <div> was an unnamed graphic to a screen reader. Every mantle part that renders a decorative icon through these primitives (Anchor, Badge, Button with asChild, Alert.Icon, AlertDialog.Icon, Empty.Icon, Toast.Icon, and the menu carets and checks) inherits the fix.

    When an icon is the only thing that conveys its meaning, pass role="img" and aria-label and it stays exposed.

  • #1478 36aceb1 Thanks @cody-dot-js! - Remove the unused color mode from InlineIconProps. No icon implemented the "auto" branded fill it described, and every consumer omitted the prop; the ngrok marks fill with currentColor, so the color CSS property sets the fill. Omit<InlineIconProps, "color"> still compiles.

  • #1478 36aceb1 Thanks @cody-dot-js! - Input no longer stamps data-disabled="false" when it receives aria-disabled="false". The data-disabled: variant matches attribute presence, so the stray value dimmed an enabled control.

  • #1478 36aceb1 Thanks @cody-dot-js! - The Kbd docs and JSDoc no longer recommend aria-label on the <Kbd>. The <kbd> element has the generic role, which prohibits naming, so a screen reader does not read that label reliably and audit tools flag it. Every example now names a symbol key with a visually hidden label inside the <Kbd> and marks the glyph aria-hidden.

  • #1478 36aceb1 Thanks @cody-dot-js! - Label stamps data-disabled instead of aria-disabled for its disabled style. A <label> has no role that supports aria-disabled, so the attribute conveyed nothing to assistive technology. Style the disabled state with data-disabled:; the old aria-disabled: hook no longer matches.

  • #1478 36aceb1 Thanks @cody-dot-js! - The List.Root, List.VirtualRoot, and List.Item summaries in the built types now match their declarations, and the props docs name isItemDisabled among the grid-only knobs that List omits.

  • #1478 36aceb1 Thanks @cody-dot-js! - Main no longer accepts id or tabIndex. It always stamps id="main" and tabIndex={-1}, because SkipToMainLink targets #main and focuses it, and a value passed through props was silently discarded before this change.

  • #1478 36aceb1 Thanks @cody-dot-js! - MediaObject.Root, MediaObject.Media, and MediaObject.Content now forward every <div> prop. Before this change they kept only className, style, children, and ref, so an id, an aria-* attribute, a data-* attribute, or an onClick was dropped in silence while the docs said "All props from div". A data-slot chain from an asChild ancestor joins ahead of each part's own slot name instead of replacing it.

  • #1478 36aceb1 Thanks @cody-dot-js! - MultiSelect tags now use a valid ARIA structure. MultiSelect.TagValues renders the tags inside a role="list" named "Selected values" (set aria-label to change it), and each MultiSelect.Tag is a role="listitem" that marks itself data-active while it has focus. Before this change every tag was a role="option" with no listbox around it, and the option role's presentational children flattened the "Remove …" button out of the accessibility tree. MultiSelect.Trigger is now role="presentation" instead of an unnamed role="group"; the input's label names the control.

    If you render custom tags through the MultiSelect.TagValues render function, give them role="listitem", and query them by that role or by data-slot="multi-select-tag" in tests.

  • #1478 36aceb1 Thanks @cody-dot-js! - OtpInput.Root documents its accessible-name contract. The hidden input needs a name: inside a Field.Item, Field.Label supplies it; on its own, pass aria-label. The docs examples that render the control on its own now do so.

  • #1478 36aceb1 Thanks @cody-dot-js! - CursorPagination.PageSizeSelect names its combobox "Items per page" by default. Its visible text is only the current value, so a screen reader had no stable name for it. Pass aria-label to override the name. The dead value attribute on the trigger button is gone.

    useOffsetPagination's goToLastPage stays on page 1 for an empty list. It set the 1-indexed currentPage to 0 and the offset to a negative number.

  • #1478 36aceb1 Thanks @cody-dot-js! - Popover JSDoc: each namespace member's summary now matches its part. Popover.Trigger opens on click; the namespace copy claimed it also opened on focus.

  • #1478 36aceb1 Thanks @cody-dot-js! - Document the accessible name contract for ProgressBar and ProgressDonut. Both render role="progressbar", so they need a name: pass aria-label, or aria-labelledby that points at a visible label. Every docs and JSDoc example now does. The JSDoc and docs also state that a value outside 0..max renders as indeterminate, list the emitted data attributes, drop a stale viewBox rationale from strokeWidth, and correct the ProgressDonut.Indicator examples to set the color with className, not the SVG color attribute.

  • #1478 36aceb1 Thanks @cody-dot-js! - QrCode.Frame is now an image to assistive technology. It always carries role="img" and takes aria-label or aria-labelledby for its name; when neither is set, the name falls back to "QR code". Pass a label that says what the code encodes, such as "QR code for ngrok.com". Before this change, a screen reader met an unnamed graphic. role is no longer a Frame prop.

  • #1478 36aceb1 Thanks @cody-dot-js! - RadioGroup accessibility and documentation fixes.

    • RadioGroup.Item shows a focus ring when it renders a custom RadioGroup.Indicator or none. Before, only the default indicator's circle drew a ring, so an item with custom children had no visible focus.
    • The items no longer forward aria-describedby from Field.Control. Headless UI strips the value, so the prop was dead; the JSDoc already said the attribute does not propagate.
    • RadioGroup.Indicator drops a name that Choice.Indicator injects for a control, so the presentational <div> no longer carries an invalid attribute.
    • The RadioGroup.InputSandbox JSDoc now states what the sandbox does: a click inside it still selects the radio and focus returns to the input, and keys other than Enter and Tab stop there.
    • The docs no longer claim that each item renders a real <input type="radio">. Each item is a div[role="radio"]; the group renders one hidden input for form submission when it has a name. The examples drop <label htmlFor> pairs, which cannot bind to a div[role="radio"], and every standalone group carries an accessible name.
  • #1478 36aceb1 Thanks @cody-dot-js! - SandboxedOnClick with asChild no longer stamps role="presentation" on the child. A presentational role on a link or a button is an ARIA conflict that user agents ignore and audit tools flag. The default <div> keeps the role, and the child keeps its own.

  • #1478 36aceb1 Thanks @cody-dot-js! - Select.Root's onBlur now fires when the trigger button blurs. Before this change the prop was stored and never called, so a form library's field.handleBlur never ran for a Select. It runs after any onBlur you pass on Select.Trigger.

    The docs now list Select.Item's icon prop and Select.Content's position prop with its "popper" default, and the Select.Root and Select.Trigger summaries in the built types carry the same validation and Field.Control guidance as the docs page.

  • #1478 36aceb1 Thanks @cody-dot-js! - The search icon in SelectableList.Filter is hidden from assistive technology. Every SelectableList part summary in the built types now matches its declaration, so hover documentation and the docs page say the same thing.

  • #1478 36aceb1 Thanks @cody-dot-js! - HorizontalSeparatorGroup accepts ref, as the docs said it did. Its props are now ComponentProps<"div"> instead of HTMLAttributes<HTMLDivElement>. The data attributes both parts emit (data-orientation, data-separator, data-horizontal-separator-group, and each data-slot) are now documented in the JSDoc and on the docs page.

  • #1478 36aceb1 Thanks @cody-dot-js! - Sheet JSDoc and docs now say the sheet slides in from the left or right edge. side accepts "left" | "right" only; the docs listed "top" and "bottom", which the type never accepted.

  • #1478 36aceb1 Thanks @cody-dot-js! - Skeleton renders aria-hidden="true" by default, as its docs already described. Pass aria-hidden={false} to opt out, which Breadcrumb.Skeleton does because its role="status" announcer lives inside the bar. The props type now uses Omit to drop children; the earlier Exclude was a no-op on an object type.

  • #1478 36aceb1 Thanks @cody-dot-js! - SkipToMainLink joins a data-slot you pass with its own "skip-to-main-link" instead of replacing it, like the layout parts do. The focus call no longer passes preventScroll, so a target below the fold scrolls into view when the link is activated and a sighted keyboard user sees where focus went.

  • #1478 36aceb1 Thanks @cody-dot-js! - Slider documentation matches the code. The color default is "bg-accent-600", not "bg-accent-500"; minStepsBetweenThumbs defaults to 1, not 0; showTicks spreads one tick per step evenly across the track, so the ticks do not land on the step values when the range is not a multiple of step; and aria-labelledby names every thumb with the same text, so a range slider should prefer aria-label, which derives a name per thumb.

  • #1478 36aceb1 Thanks @cody-dot-js! - Document the data-slot="split-button" and data-size attributes that SplitButton.Root emits, in the JSDoc and the docs API reference.

  • #1478 36aceb1 Thanks @cody-dot-js! - A click on a read-only Switch now reaches ancestor click handlers. The guard still calls preventDefault, which Radix reads to skip the toggle, but no longer calls stopPropagation, so a clickable row around a read-only switch gets its click.

  • #1478 36aceb1 Thanks @cody-dot-js! - Table.Header defaults scope to "col". Pass scope="row" for a header cell inside a body row. The docs now say that Table.Root attaches ref to its inner scroll container and that Table.Caption is the table's accessible name.

  • #1478 36aceb1 Thanks @cody-dot-js! - Tabs.Trigger with asChild no longer forces tabIndex={0} onto its child. Radix rotates tabIndex so only the active tab is a Tab stop, and the forced value put every tab, for example every router link in a list, into the Tab order.

    Tabs.List no longer scrolls a trigger into view when a pointer press focuses it. It scrolled the pressed tab to the center before the click fired, so an off-center asChild link moved out from under the pointer and never navigated. Keyboard focus still scrolls the focused tab to the center.

  • #1478 36aceb1 Thanks @cody-dot-js! - ThemeDropdownMenuRadioGroup now names its role="group": "Theme" by default, aria-label to rename it, or aria-labelledby to point at a visible heading. Before this change the group had no name. The pre-hydration Skeleton inside ThemeSwitcher.Trigger renders as a <span>, so the server HTML no longer puts a <div> inside a <button>. The ThemeSwitcher.Trigger and ThemeSwitcher.Content summaries in the built types now match their declarations.

  • #1478 36aceb1 Thanks @cody-dot-js! - useTheme throws outside a ThemeProvider, as its JSDoc always claimed. Before this change the context was seeded with a ["system", () => null] tuple, so a call outside the provider returned a setter that did nothing.

  • #1478 36aceb1 Thanks @cody-dot-js! - - Toast.Icon renders a custom svg for intent="info". Before this change the info branch ignored svg and always drew the default icon.

    • Toaster converts a duration_ms of 0 or less to Number.POSITIVE_INFINITY, as the docs describe and as makeToast already did. Before this change 0 fell back to sonner's 4000ms default and a negative value dismissed at once.
    • Toast.Root no longer advertises asChild in its props type. The root renders the intent accent bar next to children, so a slot always threw at runtime.
  • #1478 36aceb1 Thanks @cody-dot-js! - Tooltip.Content no longer advertises asChild in its props type. The content renders its own arrow next to children, so a slot always threw at runtime. Tooltip.Root no longer stamps a data-slot, because the Radix root renders no DOM for it to land on.

  • #1487 467350f Thanks @cody-dot-js! - When type, value, and the set of open sections are unchanged, Accordion.Root now keeps its context identity across parent renders. Before this change, every parent render rebuilt the context, so:

    • every Accordion.Item recomputed its state,
    • every Accordion.Content removed and re-added its beforematch listener, and
    • a memo() wrapper around an item never skipped a render.

    The accordion still calls the latest onValueChange you pass.

  • #1487 467350f Thanks @cody-dot-js! - AlertCenter.Bar now compiles under the React Compiler. It keeps the most recent top alert in React state instead of a ref it read during render; that ref read made the compiler skip the whole component. Nothing changes at runtime: when the alerts empty, the chrome keeps that alert's intent and data-alert-id until the exit slide completes.

  • #1486 2824f38 Thanks @cody-dot-js! - AlertCenter.Item takes an optional order, so you can decide which of two same-intent alerts the bar shows.

    The center ranks alerts by intent, then by order, then by arrival. Lower comes first. Items that share an order keep arrival order, and an item with no order follows every item that declares one, so ordering your own alerts never lets another author's items cut in. intent still decides the tier: a warning with order={0} never outranks a danger.

    Set it when two same-intent alerts come from independent sources. Two queries answer in either order, so before this change the bar showed whichever alert registered first, and nothing later repaired it, because arrival order is sticky for the store's lifetime. Every current consumer keeps its current order: an item with no order ties with every other undeclared item, so the comparator falls through to arrival. See the AlertCenter.Item API.

  • #1485 1d64033 Thanks @cody-dot-js! - Alert.DismissIconButton now moves keyboard focus to a neighbor when its dismissal unmounts the alert. Before, the unmount removed the focused button and focus fell to <body>. The next Tab restarted at the top of the page, and a screen reader user got no confirmation that the alert left. Now, when the alert unmounts while the dismiss button holds focus, focus moves to the nearest tabbable element outside the alert: the next one in document order, or the previous one when nothing follows. If your onClick handler or a layout effect places focus first, that choice stands. AlertCenter.DismissIconButton is unchanged: the center still lands focus on the next alert's control, or on the main landmark when no alert is left. The docs page shows the pattern under Dismissal.

  • #1477 26161ca Thanks @cody-dot-js! - Document how to announce a dynamic Alert. Alert.Root has no ARIA role of its own, so an alert that your code inserts after an action is silent to a screen reader. Pass role="alert" so a screen reader announces it. Assistive tech can miss a live region that mounts already populated, so for an error the user must act on, also move focus to the alert with tabIndex={-1}. For a less urgent message, pass role="status" on a region that is already mounted and write the message into it. The role passes through to the rendered <div> as before; the JSDoc and the docs page now show the pattern with a live example.

  • #1482 9dba9ea Thanks @cody-dot-js! - Bump runtime dependencies: @ariakit/react to 0.4.39 and @tanstack/react-virtual to 3.14.11.

    The @ariakit/react bump changes the DOM of MultiSelect.Content, which renders a modal popover by default. A modal popover with no dismiss button renders a visually hidden fallback button so assistive technology users who cannot press Escape or click outside are not trapped. That button used to be the first child of the listbox element, where a listbox may not own a button. It now renders next to the listbox, still inside the modal context. The button is still hidden and still not reachable with Tab, so only a CSS selector on the popover's first child or a test that queries for a button inside the listbox sees the change.

    The @ariakit/react bump also fixes a one-frame stale highlight in Combobox and MultiSelect: with autoSelect on, the previously active option no longer stays highlighted for one frame after the filtered list changes.

    The @tanstack/react-virtual bump reaches List.VirtualRoot and SelectableList.VirtualViewport. When a row grows while the list is pinned to the end, the virtualizer re-issues the scroll compensation the browser clamped, so the viewport no longer lands short of the last row.

  • #1473 fc58b80 Thanks @dependabot! - Bump build and runtime dependencies: tsdown and @tsdown/css to 0.23.0, and oxc-parser to 0.149.0.

    tsdown builds all three packages, so every published bundle and .d.ts file now comes from the new bundler. The public API does not change.

    oxc-parser is a runtime dependency of @ngrok/mantle-vite-plugins and @ngrok/mantle-server-syntax-highlighter, so an install of either package resolves the new version.

  • #1487 467350f Thanks @cody-dot-js! - The Calendar JSDoc and docs page now state the server-rendering contract. If you omit today, DayPicker reads the clock during render, once on the server and again in the browser. If the two clocks or time zones disagree on the date, the today highlight and its Today, … label land on different days. React then reports a hydration mismatch. Under SSR, pass today and defaultMonth (or month) from one request-scoped date. Nothing changes at runtime.

  • #1487 467350f Thanks @cody-dot-js! - Calendar now passes DayPicker one stable components object, and a classNames object that changes only when your className, classNames, or mode changes. Before this change, every Calendar render handed DayPicker two new objects, so DayPicker rebuilt its date model and month grid on each render and remounted both navigation carets. A controlled picker re-renders on every day click, so that rebuild ran on each click. Selection, navigation, and your classNames and components overrides behave as before.

  • #1487 467350f Thanks @cody-dot-js! - LineChart, AreaChart, and ScatterPlot read data faster on continuous x scales. On a "time" scale the chart used to keep one Date object per row for the life of the data, so a streaming chart with 100,000 rows carried 100,000 Date objects across every append. The chart now builds the Date (or the number on a "linear" scale) when it publishes a hover snapshot or an onDatumActivate event, so each data update is faster and no per-row Date stays resident. The xValue you receive is unchanged in type and value; it is now a fresh Date per snapshot, so a Date you mutate in a handler never changes what the tooltip shows next. Band and point scales keep the raw category values as before.

  • #1487 467350f Thanks @cody-dot-js! - Cut per-frame work in the chart paint loop. A stacked chart above 2,500 rows, or one dense enough to decimate, re-sorted its series list twice per series on every animated frame and again on every hover sync to find each series' stack boundary; the paint loop now reads the boundary by the series' paint index. The hover overlay and the hover snapshot now read a cached series list instead of re-sorting the series on every sync. The chart now creates the reduced-motion media query once instead of once per animated frame. It still reads the live preference, so a mid-session toggle snaps the next frame as before. Nothing visible changes.

  • #1487 467350f Thanks @cody-dot-js! - A chart no longer keeps an enter transition for every series dataKey it ever rendered. A long-lived chart whose series set churns, such as a top-N chart that swaps keys as traffic shifts, used to accumulate one transition per key for the life of the chart and tick each of them on every animated frame. When a series part unmounts, the chart now drops its enter transition. One behavior follows from this: a series part that unmounts and later mounts again plays its enter transition again, the same as a new series. A prop change on a mounted series still keeps its transition state.

  • #1487 467350f Thanks @cody-dot-js! - A yDomain change on a chart no longer re-reads the data. Changing yDomain on LineChart, AreaChart, BarChart, or ScatterPlot used to re-ingest every row of every series, rebuild the stack, and drop the decimation cache, although none of those depend on the y domain. The chart now re-aims the value axis directly, so a slider or a toggle that drives yDomain from state stays smooth on large data. With animate on, the axis still glides to the new domain. With animate off, or under reduced motion, it snaps.

  • #1487 467350f Thanks @cody-dot-js! - The chart Root, the tooltip surface, and CopyButton no longer use three patterns the React Compiler rejects: a ref written through the context state object, a consumer ref written directly inside an effect, and optional calls inside a try block. Behavior does not change. A consumer ref on a Tooltip part still receives the tooltip element, honors a callback ref's cleanup, and resets an object ref to null on unmount.

  • #1487 467350f Thanks @cody-dot-js! - Hovering or keyboard-stepping a chart no longer re-renders the sr-only data table or the legend. Both parts read only the registered series, which a hover publish carries unchanged, so they now subscribe to the series alone. Before this change a dense line chart reconciled up to 150 table rows on every index change under the pointer, which competed with the canvas overlay for the frame. The tooltip and the live region still update on every hover.

  • #1487 467350f Thanks @cody-dot-js! - The chart engine now reads each series' point glyph from the published series metadata instead of resolving the slot assignment again on every painted frame. Line markers, scatter points, and the 3D scatter allocate less per frame during tweens and rotation drags. The glyph a series wears does not change.

  • #1487 467350f Thanks @cody-dot-js! - A chart re-render with unchanged Tooltip props no longer re-registers the tooltip. Before this change, every render of a parent that composed BarChart.Tooltip, LineChart.Tooltip, AreaChart.Tooltip, or ScatterPlot.Tooltip tore down and re-created the registration. Each registration republished to the legend, the tooltip, the live region, and the sr-only data table. It also repainted the canvas with nothing changed. A dashboard that re-renders once a second for an unrelated reason paid a full repaint per chart per second. The part now re-registers only when one of its props changes by identity. An inline function or object still counts as a change on every render: a labelFormat, valueFormat, footer, or children function, a footer element, or an onClick handler or style object among the div props. Pass stable values to skip the republish.

  • #1487 467350f Thanks @cody-dot-js! - Checkbox now sets the native indeterminate property in a layout effect, before the browser paints. Before this change, a Checkbox with checked="indeterminate" or defaultChecked="indeterminate" could paint one frame as a plain unchecked box on mount, after hydration, or after an async update, and then switch to the indeterminate visual.

  • #1487 467350f Thanks @cody-dot-js! - A callback ref on CodeBlock.Code is now called once, when the <pre> mounts, as long as you pass the same function on each render. When the <pre> unmounts, React runs the cleanup your callback returned, or calls the callback once with null. Before this change every re-render of the block, including each CodeBlock.ExpanderButton click, called the ref with null and then with the node again, so a ref that measures or observes the <pre> did its setup work several times per block.

  • #1487 467350f Thanks @cody-dot-js! - A collapsible CodeBlock that mounts on the client now paints collapsed on its first frame. CodeBlock.ExpanderButton registers with CodeBlock.Root in a layout effect, so the <pre> carries data-state="collapsed" before the browser paints the mount. Before this change the block painted at full height for one frame and then snapped to the collapsed height, which shifted the content below it on every client-side navigation. Server HTML still renders the <pre> without data-state, so a server-rendered block collapses at hydration as before.

  • #1487 467350f Thanks @cody-dot-js! - CodeBlock.CopyButton now compiles under the React Compiler. Its click handler no longer calls onClick and onCopy with ?.() inside the try block. The compiler cannot lower that shape, so it left the whole component uncompiled. CopyButton now gets the same compiler memoization as its siblings. Nothing changes at runtime: onClick, onCopy, and onCopyError fire as before.

  • #1487 467350f Thanks @cody-dot-js! - CodeBlock.ExpanderButton now carries aria-controls in the server HTML. CodeBlock.Root generates the <pre> id, so the button and the <pre> agree before hydration. Before this change the id reached the button through a mount effect, so the server HTML and the first client render had a button with aria-expanded but no aria-controls. The <pre> id is still a generated value with no stable format.

  • #1487 467350f Thanks @cody-dot-js! - Combobox.Item no longer wraps each item in a context provider that nothing read. Combobox.ItemValue takes the item value from ariakit's own item context, as before, so the rendered DOM, the data attributes, and the match highlighting are unchanged. A long list mounts one React node less per item.

  • #1487 467350f Thanks @cody-dot-js! - Command.DialogRoot now reads onOpenChange through a ref, so the value useCommandDialog() returns keeps its identity across parent renders. Before this change, an inline onOpenChange arrow gave that value a new identity on every parent render. Every consumer of the hook, Command.SearchTrigger and Command.Input included, re-rendered with it. The callback you pass still runs on every open and close, and the latest one always wins.

  • #1487 467350f Thanks @cody-dot-js! - Command.Empty renders nothing in the server HTML and in the hydration pass. cmdk counts matching items in a layout effect, which never runs on the server. Before this change, a server-rendered inline Command.Root palette painted "No results found." above its full list until hydration. The message now appears only on the client, and only when no item matches the query. A palette with no items shows its empty state one render after hydration instead of in the server HTML.

  • #1487 467350f Thanks @cody-dot-js! - A callback ref on Checkbox, Select.Root, Select.Trigger, Table.Root, Tabs.List, or TextArea now fires once when the element mounts, as long as you pass the same function on each render. When the element unmounts, React runs the cleanup your callback returned, or calls the callback once with null. Before this change, every re-render of the component detached and re-attached the ref, so your callback ran with null and then with the element again on each render. A ref that measures the element, registers it, or starts an observer now does that work once. A Select.Trigger re-render also no longer round-trips two state updates through the Radix Select root. Dialog.Content, Sheet.Content, and AlertDialog.Content no longer pay an extra render pass on each re-render.

    If you pass a different ref on a later render, React detaches the old ref and attaches the new one at once, as it does on a plain element. Accordion, Alert, AlertCenter, Breadcrumb, Chart, Choice, List, and Sandbar already fired your ref once per mount, but they attached a swapped ref only when the element next mounted. They now attach it at once too.

  • #1483 636934f Thanks @cody-dot-js! - CursorPagination.Root accepts a controlled pageSize. Pass it with onChangePageSize to own the page size yourself, for example in a URL search param, and CursorPagination.PageSizeSelect shows the value you pass. Before this change the select was uncontrolled and seeded once from defaultPageSize, so a page size that changed outside the component, such as a browser history move that rewrote the URL, never reached the select. defaultPageSize still works for the uncontrolled case; pass exactly one of the two. onChangePageSize on Root also runs in the uncontrolled case, so you can read the change without a callback on PageSizeSelect.

    CursorPagination.PageSizeSelect now renders its trigger text, such as "50 per page", in the server HTML. Before this change Radix filled the trigger on the client after hydration, so the first paint showed an empty trigger.

    The DataTable pagination recipe, in its JSDoc and on both docs pages, now passes the table's live page size as pageSize, so the select follows a table.setPageSize() call from anywhere.

  • #1486 2824f38 Thanks @cody-dot-js! - DataTable.Row and DataTable.ActionCell now guard a clickable row the way the docs always said a consumer should.

    • DataTable.ActionCell stops a click inside it from reaching the row's onClick, and keeps the click's default action, so an action menu opens without the row navigating. Before, the cell only documented that guard, and a forgotten onClick={(event) => event.stopPropagation()} navigated the user away when they opened the menu. Your own onClick on the cell still runs, and the <td> keeps its role="cell". The stopPropagation handler in your code is now redundant and can go.
    • DataTable.Row runs onClick only for a plain primary click. A modified click (⌘, Ctrl, Shift, Alt), a non-primary button, and a click that ends a text selection inside the row all skip it. The link inside the row answers a modified click with a new tab, and a drag-select copies text, so neither is a request to navigate.
    • DataTable.Row stamps data-clickable when onClick is set, so a test or a stylesheet has a stable hook instead of the cursor-pointer class.
    • A Table.Body row now highlights when a link or button inside it has visible focus, the same way it highlights under the pointer, so a keyboard user sees the row-scale target too.
    • The docs now say why the link in the primary cell is the keyboard path, why the row keeps role="row", and how to wrap that link in SandboxedOnClick with asChild and allowClickEventDefault. See Navigating on row click.
  • #1487 467350f Thanks @cody-dot-js! - Dialog.Content, Sheet.Content, and AlertDialog.Content now let Radix own aria-describedby. When you render a Description part, the content's aria-describedby points at that description as soon as the content opens. When you render none, the content carries no aria-describedby. Before this change, a mantle wrapper tracked the same presence in its own state and effect, so every root and every open paid for that work twice. An explicit aria-describedby on the content still wins.

  • #1477 26161ca Thanks @cody-dot-js! - Field.Item now takes an id for the focusable control. Field.Control splats it onto the control and Field.Label uses it as the default htmlFor, so a Playwright locator or an anchor link can find the control by a stable id. When omitted, Field.Item generates a stable id as before. An id on the control element itself is still overwritten: Field.Item owns the control's id, name, aria-*, and validation.

    Before this change, an id on Field.Item landed on its wrapping <div>. It now names the control, and the wrapper carries no id. This matches Choice.Root, whose id also names the control. The migration guide is at mantle.ngrok.com/migrations/field-item-owns-control-id-migration.

    <Field.Item name="password" id="login-password">	<Field.Label>Password</Field.Label>	<Field.Control>		<PasswordInput autoComplete="current-password" />	</Field.Control></Field.Item>
  • #1477 26161ca Thanks @cody-dot-js! - Field.Label no longer takes htmlFor, and Field.Control no longer takes id. Field.Item owns the control id: its id prop names the control, Field.Control splats it onto the focusable child, and Field.Label always points its htmlFor at it. A label and its control can no longer drift apart. Outside a Field.Item, Field.Label renders a <label> with no for attribute; use Label from @ngrok/mantle/label there.

    Migration: delete every htmlFor on Field.Label and every id on Field.Control. Move a literal id that must stay stable to id on the surrounding Field.Item. The full guide, with before and after examples, is at mantle.ngrok.com/migrations/field-item-owns-control-id-migration.

  • #1487 467350f Thanks @cody-dot-js! - Field.Item, Field.Errors, and Field.ErrorList now document when the inferred "error" state applies. The inference runs on the client, so it applies after hydration. The server HTML carries the error list. Until the page hydrates, the control is not marked invalid (aria-invalid="true") and has no aria-errormessage, and the item has no data-validation="error". For an error that renders on the server, set validation={messages.length > 0 && "error"} on Field.Item; the explicit state is in the server HTML from the first paint. The docs page gains a "Server-rendered errors" section with that idiom.

    Field.Item also keeps its context identity stable when validation is explicit. Before this change, the first message a Field.Errors or Field.ErrorList mounted or unmounted rebuilt the context, so Field.Label, Field.Control, and Field.Description re-rendered on that transition. The rendered DOM and ARIA output are unchanged.

  • #1487 467350f Thanks @cody-dot-js! - isCountryCode now checks a Set built once at module load instead of scanning the 751-entry countryCodes array on every call, so narrowing one code per table row no longer costs a linear scan per row. The results are unchanged: a string in countryCodes returns true; a lowercase code, a number, an array, null, or undefined returns false.

  • #1487 467350f Thanks @cody-dot-js! - MetaKey, Command.SearchTrigger, and Sidebar.Trigger now resolve the host platform in the first render of a client mount. Before this change they rendered the non-Apple answer (⌃, Control+K, Control+B), committed, and then corrected themselves in an effect, so a palette that opened on macOS committed twice and could show ⌃ before ⌘. The server HTML and the hydration render still carry the non-Apple answer, and React still corrects them once after hydration, so nothing changes for a server-rendered page's first paint.

  • #1487 467350f Thanks @cody-dot-js! - useBreakpoint now returns the real breakpoint in the first render of a client mount. Before this change, the first client render of any consumer returned "default". React then forced a second render with the real value, so a layout keyed on the breakpoint could paint its mobile variant for one frame on a desktop viewport. The same happened on every mount after the last consumer unmounted and the viewport changed in between. The server render and the hydration render still return "default".

  • #1487 467350f Thanks @cody-dot-js! - useMatchesMediaQuery now constructs one MediaQueryList per hook instance and reuses it across renders and change notifications. Before this change every render called window.matchMedia again, which parsed the query and allocated a new list each time. The returned value and the change subscription behave as before.

  • #1487 467350f Thanks @cody-dot-js! - usePrefersReducedMotion now returns the real preference in the first render of a client mount. Before this change every mount rendered true (reduce motion), committed, and then re-rendered with the real value from an effect. A dialog or carousel that mounted after hydration and picked its duration from the hook started with no animation, then flipped to the animated variant after paint. Its entrance animation skipped or restarted. The server render and the hydration render still return true, so nothing animates before hydration.

  • #1487 467350f Thanks @cody-dot-js! - useUndoRedo now derives canUndo and canRedo from state instead of reading its history refs during render. The history itself still lives in refs, so two undo or redo calls inside one event handler still return successive snapshots. The returned object and the re-render timing are unchanged; the hook now follows the Rules of React.

  • #1487 467350f Thanks @cody-dot-js! - A callback ref passed to Input or InputCapture now fires once when the <input> mounts, as long as you pass the same function on each render. When the <input> unmounts, React runs the cleanup your callback returned, or calls the callback once with null. Before this change, every re-render of the component detached and re-attached the ref, so the callback saw null and then the element again on each re-render. A callback ref that measures the element or sets state no longer pays a layout read or an extra render per re-render.

  • #1487 467350f Thanks @cody-dot-js! - isInput from @ngrok/mantle/input now returns false on the server. Before this change, a call with a non-null value in a loader, an action, or a render helper threw ReferenceError: HTMLInputElement is not defined, because Node declares no HTMLInputElement. In the browser nothing changes.

  • #1487 467350f Thanks @cody-dot-js! - MultiSelect.Content now decides whether to turn off Ariakit's body scroll lock from the enclosing overlay's layer container alone, instead of also probing the DOM for a data-mantle-modal-content ancestor after mount. Inside Dialog, Sheet, and AlertDialog the result is the same, because each renders its content inside its layer container. If your own overlay carries data-mantle-modal-content outside a mantle overlay, Ariakit's lock now stays on there; pass preventBodyScroll={false} to turn it off. As before, preventBodyScroll overrides the default in either direction.

  • #1487 467350f Thanks @cody-dot-js! - MultiSelect.Item now reads the lockedValues of MultiSelect.TagValues from React state that MultiSelect.Root owns, instead of a ref that TagValues wrote during render. A value you lock or unlock while the popover is open now takes effect on the next click, even when your items keep the same element identity across renders (a memoized item list, or a consumer compiled with the React Compiler). Before, such an Item kept the lock state from its first render: a click in the popover could still deselect a newly locked value, and a newly unlocked one stayed stuck. The lock also no longer depends on MultiSelect.Trigger rendering before MultiSelect.Content. TagValues and Item now compile under the React Compiler; the render-time ref writes and reads made the compiler skip both parts before.

  • #1487 467350f Thanks @cody-dot-js! - When MultiSelect.TagValues unmounts, it now drops its keyboard handler and its locks. Before, MultiSelect.Input kept calling the handler from the last render of TagValues, so Backspace on an empty input removed the value that was last at that time. MultiSelect.Item also kept honoring locks for a tag list that no longer existed.

  • #1483 636934f Thanks @cody-dot-js! - useOffsetPagination accepts defaultPage, a controlled page with onPageChange, and resetPageOnListSizeChange. Pass page and onPageChange to keep the page outside the hook, for example in the URL; the hook calls onPageChange with the next page and reads the page back from the prop. Set resetPageOnListSizeChange: false to stay on the current page when listSize changes: currentPage clamps to the new totalPages instead of returning to page 1, so a background refetch that changes the list length no longer moves the user. The default stays true. currentPage normalizes a page or defaultPage: a value past the last page clamps to it, a fraction rounds down, and NaN reads as 1, so a malformed URL param cannot produce a NaN offset.

  • #1487 467350f Thanks @cody-dot-js! - PasswordInput now treats showValue as a controlled prop, as its docs state. When you pass showValue, the toggle button no longer flips the visibility on its own: it calls onValueVisibilityChange with the next value, and the input follows the prop. Before this change, a click revealed the value even while showValue stayed false, so the input drifted from the prop until the prop changed. A showValue change now also applies in one render, with no intermediate frame that shows the old type, aria-pressed, or icon.

    If you pass a static showValue and rely on the built-in toggle to flip the visibility, pass onValueVisibilityChange and write the next value back into showValue, or omit showValue to keep the toggle uncontrolled.

  • #1477 26161ca Thanks @cody-dot-js! - Make the PasswordInput visibility toggle a keyboard-reachable toggle button, and give the input a stable id.

    • The toggle is now in the tab order. Enter and Space toggle visibility and keep focus on the button. It was tabIndex={-1} before, so a keyboard user could not reach it.
    • The toggle carries aria-pressed for its state and aria-controls that points at the input.
    • When disabled is set, the toggle is disabled too. Tab skips the component, and Enter or Space cannot reveal the value.
    • The accessible name is now the fixed string "Show value". It no longer changes with state, and it no longer contains "password", so a substring label query for the input (getByLabel("Password") in Playwright) matches one element. The previous names were "Turn password visibility on" and "Turn password visibility off".
    • The input uses the id you pass, or a generated one when you pass none. Inside a Field.Item, the id comes from Field.Item's id prop.
    • The toggle stamps data-slot="password-input-toggle".

    Input no longer moves focus into the input when a keyboard user activates a focusable adornment inside it. A pointer click on an adornment still focuses the input.

    Update tests that select the toggle by its old name. The migration guide is at mantle.ngrok.com/migrations/field-item-owns-control-id-migration.

  • #1487 467350f Thanks @cody-dot-js! - ProgressDonut.Root now compiles under the React Compiler. It builds the track circle's style object before the context useMemo, so the compiler can preserve that memo instead of skipping the component. Nothing changes at runtime: the rendered <svg>, its ARIA attributes, its data attributes, and the --radius variable are the same.

  • #1486 2824f38 Thanks @cody-dot-js! - A Choice.Description inside a RadioGroup item is now the radio's aria-describedby, and the Choice.Title alone is its accessible name.

    role="radio" flattens its children, so before this change a Choice.Title and Choice.Description inside a radio merged into one name, such as "Free Up to 3 projects and 1 member." Now Choice.Title and Choice.Description register their ids with the radio item, which points aria-labelledby at the title and aria-describedby at the description. This works in every item variant: RadioGroup.Item, RadioGroup.ListItem, RadioGroup.Card, and RadioGroup.Button. A Choice next to a checkbox or a switch is unchanged, and Choice.Title now carries an id, which defaults to a generated one.

    The same change lets a radio item inside Field.Control pick up the field's aria-describedby, so Field.Description and Field.Errors now describe each radio in that composition. Before, Headless UI's Radio wrote its own empty aria-describedby over the value, and the docs said the attribute did not propagate. Each item variant now renders its own element through Headless UI's as prop and stamps the ids after that spread. See Rich option layout with Choice.

  • #1487 467350f Thanks @cody-dot-js! - SelectableList.Root no longer re-renders every mounted row when a parent render passes a new inline onValueChange or onQueryChange. Before this change, an inline callback (onValueChange={(values) => form.setValue("keys", values)}) rebuilt the list context on every parent render, so every row re-rendered even when value, options, and query were unchanged. Root still calls the latest onValueChange and onQueryChange you pass.

  • #1487 467350f Thanks @cody-dot-js! - Sidebar.ItemButton and Sidebar.SearchTrigger now merge their shared row classes through the cx argument cache. Before this change, each row render rebuilt and re-hashed a long class string. A navigation that re-renders every row of a large sidebar paid that cost once per row. The rendered className is unchanged.

  • #1487 467350f Thanks @cody-dot-js! - The Table and Tabs source files now start with the "use client" directive. The published build strips the directive, so nothing changes for you today. A host that reads the source in a React Server Components tree now sees Table and Tabs as client components.

  • #1487 467350f Thanks @cody-dot-js! - ThemeProvider now compiles under the React Compiler. Its effect cleanup held optional chaining inside a try block. The compiler cannot lower that shape, so it skipped the whole component. The cleanup now calls a module function that owns the try. Behavior is unchanged: the cross-tab BroadcastChannel still closes on unmount, and a close() that throws is still swallowed.

  • #1487 467350f Thanks @cody-dot-js! - ThemeProvider now takes an optional ssrCookie prop: the theme cookie pair from extractThemeCookie that MantleStyleSheets and useInitialHtmlThemeProps already accept. On the server, the provider seeds useTheme and useAppliedTheme from it, so the hydrated client renders the same markup as the server. Before this change the server always rendered system. A user with a stored theme then hit a hydration mismatch in every component that rendered from the theme on the server. On a dark-theme page, AutoThemeIcon kept the server's sun glyph after hydration. The client still reads document.cookie, so an omitted ssrCookie behaves as before. Pass the value from your root loader: <ThemeProvider ssrCookie={loaderData?.ssrCookie}>.

  • #1487 467350f Thanks @cody-dot-js! - useCallbackRef from @ngrok/mantle/hooks now accepts a callback with typed parameters, such as (values: string[]) => void. Before this change, its generic constraint rejected any callback whose parameters were narrower than unknown, so only a callback with no parameters, or with parameters typed unknown, compiled. The returned function keeps the exact signature of the callback you pass.

  • #1487 467350f Thanks @cody-dot-js! - List.VirtualRoot and SelectableList.VirtualViewport now render the first slice of rows in the server HTML. Before this change, the server emitted the collection chrome with its full height and aria-rowcount but zero rows, so a server-rendered long list showed a tall empty box until hydration and no rows at all without JavaScript. estimateItemHeight and overscan size the slice; the rest of the rows mount after hydration measures the real viewport. The first client render matches the server, so this adds no hydration mismatch.

  • #1487 467350f Thanks @cody-dot-js! - List.VirtualRoot and SelectableList.VirtualViewport no longer re-render every mounted row each time the visible window moves. The row key function handed to the virtualizer now keeps its identity across renders, so the virtualizer keeps its measurements and a scroll over measured rows re-renders only the rows that enter or leave the window. A row whose measured height differs from estimateItemHeight still re-renders the mounted rows once on the frame that measures it. Row keys still follow your key props across a reorder or filter.

0.84.1

Patch Changes

  • #1470 b5f83e8 Thanks @cody-dot-js! - Choice.Description now forwards its click to the sibling Choice.Label, so the whole content column toggles the control. The accessible name stays the label alone, and the description stays wired through aria-describedby.

    The forward skips a click on a link, a button, a form control, a nested label, a <details>, or an ARIA widget inside the description: the same rule a List row applies to its own click-to-activate. It runs after a consumer onClick, so event.preventDefault() cancels it. After it forwards, it marks the click handled, so a click on a SelectableList row's description toggles the row once. When the choice uses Choice.Title, the description forwards nothing, because an ancestor already owns that click. The description also shows the pointer cursor when a Choice.Label precedes it.

    List and SelectableList rows now also defer a bare row click that lands inside a <details>, so a <summary> toggle no longer activates the row.

  • #1471 b5dd7a6 Thanks @cody-dot-js! - useLocalStorage and useSessionStorage now guard every storage access. Before, a setItem that threw (a full origin, Safari private browsing, blocked site data) skipped the sync event, so the hook's value never advanced and the caller got no signal: a dismissible banner that gates its visibility on the stored value never closed. A storage read that threw during render took down the subtree.

    If a write fails, the hook now keeps the value in memory, so every same-key instance still advances until the page reloads. A later write that succeeds, or a storage event from another document, restores storage as the source of truth. If the browser denies storage access, the read resolves to the default instead of throwing.

  • #1470 b5f83e8 Thanks @cody-dot-js! - Slot no longer erases a component child's own data-slot. It passed data-slot: undefined to the child when neither side set one, and a part that spreads its props after its own data-slot lost the attribute. Field.Control wraps its child in Slot, so a Checkbox or Choice.Root inside one rendered without data-slot="checkbox" or data-slot="choice".

0.84.0

Minor Changes

  • #1463 178b4d3 Thanks @cody-dot-js! - Add LiveRegion, a persistent, visually hidden live region that announces its text to screen readers when the text changes. The politeness prop picks the urgency: "polite" (the default) renders role="status" and aria-live="polite", and "assertive" renders role="alert" alone, because a redundant aria-live makes VoiceOver on iOS announce the message twice. The region stamps aria-atomic="true" and data-slot="live-region", takes asChild, and treats the derived role and aria-live as defaults a consumer prop can override. Keep the region mounted from first paint and swap only its children: a region created after the fact announces unreliably.

    Docs: https://mantle.ngrok.com/components/primitives/live-region

  • #1463 178b4d3 Thanks @cody-dot-js! - Add VisuallyHidden, a primitive that renders content for screen readers without showing it. It renders a <span> clipped to a one-pixel box (never display: none), stamps data-slot="visually-hidden", and takes asChild to hide a more semantic element (a <caption>, a <legend>, a heading). Do not use it to name an icon-only control: voice-control software skips a control whose only label is hidden DOM text, so use aria-label there instead.

    Docs: https://mantle.ngrok.com/components/primitives/visually-hidden

Patch Changes

  • #1459 edbca2c Thanks @dependabot! - Bump runtime dependencies: @ariakit/react to 0.4.38.

    The ariakit bump changes keyboard activation in mantle's ariakit-backed parts (Combobox, MultiSelect): the click that Enter or Space synthesizes on an item is now a PointerEvent with pointerId: -1 and an empty pointerType, the same shape browsers dispatch for a click no pointer caused, so an onClick handler that reads event.pointerType or checks instanceof PointerEvent now sees the native shape.

    It also fixes hover handling while the popup is closed: an item's hover no longer activates it or clears the active item, so a collapsed list no longer changes value or moves focus on a stray hover.

  • #1459 edbca2c Thanks @dependabot! - Sandbar now attaches a rejection handler to the shake animation's finished promise. Canceling a shake (a rapid second blocked attempt, or unmount mid-wiggle) rejects that promise with an AbortError, which surfaced as an unhandled rejection in test environments such as happy-dom 20.12.0.

  • #1464 86758b4 Thanks @cody-dot-js! - Update the source-all.css header comment: it no longer recommends the retired mantleTwSourcePlugin for apps that use a subset of mantle. Import source-all.css alongside mantle.css to register mantle's Tailwind sources.

0.83.9

Patch Changes

  • #1450 32a7532 Thanks @cody-dot-js! - IconButton and PasswordInput's visibility toggle now carry their accessible name as aria-label instead of a visually hidden <span>. Voice-control tools (Rango, Voice Control, Voice Access) treat DOM text as a visible label, so they skipped these icon-only buttons. Screen readers announce aria-label the same way, so nothing changes for them.

    IconButtonProps now rejects aria-label (typed never): the label prop is the single way to name the button. Components built on IconButton (ThemeSwitcher.Trigger, Sidebar.Trigger, DataTable.RowExpandButton, CodeBlock.CopyButton, and the chart copy buttons) inherit the fix.

  • #1427 00c346c Thanks @dependabot! - Bump runtime dependencies: @ariakit/react to 0.4.37, @tanstack/react-virtual to 3.14.10, input-otp to 1.5.0, and sonner to 2.0.8.

    The ariakit bump changes two behaviors in mantle's ariakit-backed parts (Combobox, MultiSelect): a select-style popup now takes focus every time it opens, not only when the trigger itself had focus, and a prop set to an explicit undefined now behaves like an omitted prop, so the component keeps its computed default.

    The input-otp bump hardens OTPInput: the browser no longer spellchecks the code, a 16px font-size fallback prevents the iOS focus zoom, and the library's own container now carries translate="no", which backs up the lock mantle already puts on each slot.

0.83.8

Patch Changes

  • #1443 d17713d Thanks @cody-dot-js! - CodeBlock.Code no longer bounces at the ends of its horizontal scroll range — overscroll-behavior-x: none also keeps the scroll from chaining to the page.

  • #1443 d17713d Thanks @cody-dot-js! - CodeBlock.Code pins the line-number gutter while the code scrolls horizontally.

    The gutter — line numbers and fold toggles — now sticks to the left edge of the scrollport, and an opaque backdrop masks the code sliding under it. Blocks without line numbers scroll as one unit, unchanged.

  • #1443 d17713d Thanks @cody-dot-js! - CodeBlock.Code no longer shows a horizontal scrollbar when every line fits the container.

    Every rendered line reserved 3.5rem of right padding to clear the copy button. That reserve widened the code's intrinsic width, so a block whose longest line fit the container still scrolled sideways. Now the full clearance sits on the first two lines only — the only lines the copy button overlaps — and only when a CodeBlock.CopyButton is rendered. Every line keeps a 1rem reserve, so a line at the end of the scroll range stays clear of the container's edge.

  • #1444 408092f Thanks @cody-dot-js! - Stack floating UI in two tiers so a background popover can never paint over an open dialog.

    Overlays — Dialog, AlertDialog, Sheet, and Command.DialogRoot — now render at z-60, above the float tier. Floats — Popover, Tooltip, Select, DropdownMenu, and HoverCard — stay at z-50 and portal into the nearest overlay's positioner instead of document.body. A float composed inside an overlay now always paints above the overlay that owns it. A float outside every overlay always paints below the overlay tier. Before this change every layer shared z-50 and the most recently mounted one won, so a popover that opened late — an onboarding popover after hydration, for example — painted on top of an open full-bleed dialog.

    The positioner is a new styling hook on each overlay: data-slot="dialog-positioner", data-slot="alert-dialog-positioner", and data-slot="sheet-positioner".

    An explicit container on Dialog.Portal or HoverCard.Portal now also carries into the parts that portal internally, so the content lands in that container — before, the inner portal ignored it.

    Notes, for the rare consumer that depended on the old stacking:

    • Custom fixed chrome at z-50 that must paint above an open mantle overlay needs z-60 or higher.
    • CSS that selects a float's portal as a direct body child (for example body > [data-radix-popper-content-wrapper]) no longer matches when the float is composed inside an overlay. Select the float's data-slot instead.

    Combobox.Content (renders in place), MultiSelect.Content (portals into the overlay's data-mantle-modal-content), and Toast (sonner's own viewport, far above both tiers) keep their placement.

0.83.7

Patch Changes

0.83.6

Patch Changes

  • #1432 391093a Thanks @cody-dot-js! - Add three Breadcrumb pieces for the crumbs that are not plain links, all documented on the Breadcrumb page:

    • Breadcrumb.Label — the crumb that is not a link: a level with no page of its own, such as a section whose index URL only redirects (Settings in Settings > General). It renders a muted <span> with data-slot="breadcrumb-label", no href, and no aria-current, and supports asChild like every other part.
    • Breadcrumb.Skeleton — the placeholder crumb for a label that is still loading: one <li> rendered as a crumb-sized pulsing bar (data-slot="skeleton breadcrumb-skeleton"), plus a screen-reader-only role="status" announcement (label defaults to "Loading breadcrumbs…"). The bar is w-24 by default; pass a w-* utility for a best-guess width, so the row keeps its shape when the real crumbs arrive.
    • The route-trail model, exported from @ngrok/mantle/breadcrumb and router-agnostic: the routeBreadcrumb factory creates link, label, and content crumbs as a discriminated Crumb union; buildCrumbs derives a ResolvedCrumb trail from any matches carrying id, pathname, and handle (react-router's UIMatch satisfies this structurally); hasBreadcrumb is the type guard; BreadcrumbHandle<TMatch> types a route's handle export.

    The breadcrumbs-from-routes recipe now builds on these exports, so the only file an app copies is the renderer. It covers the prefix crumb (routeBreadcrumb.label) and the query-backed crumb: a content crumb shares the page's query and falls back to Breadcrumb.Skeleton while the query is pending.

0.83.5

Patch Changes

  • #1412 3d5b964 Thanks @cody-dot-js! - IconButton gains the filled appearance, and narrows intent to "neutral".

    Before this, IconButton stopped at ghost and outlined. A primary action that happened to be icon-only had no way to carry the weight its text-labelled sibling carried, so call sites either dropped to outlined or hand-rolled the fill. filled closes that: appearance="filled" intent="neutral" is the same solid box on both components.

    intent now takes "neutral" alone. An icon carries no text to name the action a tone colors, so an accent or danger icon button reads as decoration rather than as a warning. intent="accent" and intent="danger" are a type error on IconButton and on the wrappers that forward the prop — Dialog.CloseIconButton, Sheet.CloseIconButton, Field.HelpTrigger, DataTable.ExpandButton, and Sidebar.Trigger. Reach for Button where the tone carries meaning. The prop stays required, so a call site reads the same on both components, and a later widening costs no second break. data-intent is still stamped, always "neutral".

    link stays off the appearance union. An icon-only link has no text to read as a link.

    One shared pair also drifted between the two components. Button's outlined + neutral kept its accent border while focused and hovered, but dropped back to the resting border the moment the button was pressed; IconButton held the accent border through the press. Button now holds it too, so a focused button and a focused icon button draw the same border for the whole click.

    <IconButton appearance="filled" intent="neutral" icon={<PlusIcon />} label="Create endpoint" /><IconButton appearance="ghost" intent="neutral" icon={<TrashIcon />} label="Delete endpoint" />
  • #1425 25ed46c Thanks @cody-dot-js! - terraform and tf join the supported code block languages. mantleCode("terraform"), Markdown fences, and the server highlighter render HCL with Shiki's Terraform grammar, which ships in the existing shiki dependency.

    Terraform blocks fold as bracket pairs — resource "type" "name" { … }, object values, and multi-line lists all get gutter toggles. Indentation normalizes to spaces, which matches terraform fmt.

  • #1419 5ee7365 Thanks @forzalupo! - TextArea now renders its default appearance at the same font size as Input and Select, and its monospaced appearance a step smaller.

    The default (non-monospaced) TextArea only set pointer-coarse:text-base, so on a fine pointer (desktop) it had no font-size class and fell back to the 16px UA/root default — 2px larger than the text-sm (14px) that Input and Select use. Adding the missing text-sm base brings it in line: 14px on desktop, 16px on touch, matching the other form controls.

    The monospaced appearance drops from the arbitrary 0.8125rem/0.9375rem sizes to text-xs/5 on desktop (12px font with the text-sm 1.25rem line-height, so its rows and baseline stay aligned with the default appearance) and pointer-coarse:text-base (16px) on touch. The 16px touch size matches the other form controls and, crucially, stays at or above the 16px threshold that iOS Safari uses to decide whether to auto-zoom into a focused field — so tapping the monospaced textarea on iOS no longer triggers a zoom.

0.83.4

Patch Changes

  • #1409 272814d Thanks @cody-dot-js! - Alert.ExpandButton renders its caret through Button's icon slot, rather than as a third child beside the count and the word.

    The slot is what the caret wanted all along. It sizes the glyph, holds it at shrink-0, tightens the padding on its side, and hands its place to the spinner when a button loads. iconPlacement="end" keeps the caret where it has always drawn, after the label.

    The control renders +3 more ⌄ as it did before, and the count keeps the min-width that stops a two-digit count from shifting the label. Its own padding moves to ps-1.5 pe-1, which keeps the caret side 0.125rem tighter than the text side.

    That last part is worth knowing at any call site. A button with an icon tightens the icon side through a ps-* or pe-* longhand, and a px-* override cannot beat a longhand — both survive the class merge, and Tailwind emits the longhand last. Override the padding of a button with an icon in longhands too.

  • #1409 272814d Thanks @cody-dot-js! - Button and Badge render their label slot with display: contents, so the wrapper 0.83.3 added changes no layout.

    That release wrapped children in a <span data-slot="button-label"> to keep a browser translation engine from crashing the page. The span was also a box, which made every child one flex item: a call site that passed more than one child lost the container's gap between them, and a min-width on a child lost the formatting context that held it. Alert.ExpandButton rendered +3more instead of +3 more ⌄.

    The span now generates no box. Every child is a flex item of the button or the badge again, the gap falls between them, and a child's own width applies — the layout matches the release before the wrapper. The span stays in the DOM, so the crash fix stands: an icon's insertBefore still names an element, never a text node the engine reparented.

    This restores the shape a chip or a picker trigger needs, where the label absorbs the slack between two icons:

    <Button	appearance="outlined"	intent="neutral"	className="max-w-48 min-w-0 justify-start"	icon={<KeyIcon />}>	<span className="min-w-0 flex-1 truncate text-left">{selectedLabel}</span>	<CaretDownIcon /></Button>

    flex-1 and min-w-0 reach that span only while it is a flex item of the button, so the wrapper's box dropped both and the label pushed past max-w-48 instead of clamping.

    Anchor is unchanged. Its label span never changed layout, because an anchor lays its children out in inline flow.

  • #1409 272814d Thanks @cody-dot-js! - OtpInput.Slot now renders translate="no", so a browser translation engine skips the passcode character inside it.

    A one-time passcode is never translatable. An engine that treats the character as prose shows the reader a character the code does not contain, and the reader types it back.

    The attribute also protects the render. Google Translate wraps each text node in a <font>, which detaches the node React holds. The next keystroke then makes React remove a node its parent no longer owns, and the DOM raises NotFoundError — which tears down the root when no error boundary sits above it. Backspacing a digit on a translated page hit this. translate="no" prevents it, because the engine never enters the slot.

    The slot omits translate from its props type, so passing it is a compile error, and it stamps the attribute after the props spread, so a wider props object cannot carry a value past the type either. Kbd and CodeBlock.Code already lock the attribute the same way.

    import { OtpInput } from "@ngrok/mantle/otp-input"; // <div data-slot="otp-input-slot" … translate="no">4</div><OtpInput.Root maxLength={6}>	<OtpInput.Group>		<OtpInput.Slot index={0} />	</OtpInput.Group></OtpInput.Root>;

0.83.3

Patch Changes

  • #1405 aebf20c Thanks @cody-dot-js! - Popover and HoverCard now export an Arrow part — a tip that points from the content at what it is anchored to. Render it as a child of the content:

    import { Button } from "@ngrok/mantle/button";import { Popover } from "@ngrok/mantle/popover"; <Popover.Root>	<Popover.Anchor asChild>		<button type="button">Anchor</button>	</Popover.Anchor>	<Popover.Trigger asChild>		<Button type="button" appearance="outlined" intent="neutral">			What moved?		</Button>	</Popover.Trigger>	<Popover.Content side="right">		<Popover.Arrow />		<p>Products moved up here.</p>	</Popover.Content></Popover.Root>;

    Radix positions the part with floating-ui's arrow middleware, so the tip stays centered on the anchor when collision detection shifts or flips the content. A hand-rolled tip is a fixed offset, so it drifts off the anchor as soon as the content moves, and it needs a pinned align per anchor to land on the right row at all.

    Mantle styles the tip to read as a continuation of the content's edge. Popover.Content and HoverCard.Content both paint a bg-popover fill behind a border-popover edge, and Radix renders the arrow as an svg, where bg-* paints nothing. Each arrow is therefore two layers: a polygon filled with the popover surface, and a polyline that strokes the two slanted edges only. The base carries no stroke and sits 1px inside the content, so the fill covers the content's own border across the base and the two border lines read as one.

    The tip carries its own shadow, built from the same --shadow-color and --shadow-first-opacity tokens the content's shadow-md uses. box-shadow stops at the content's border, so a tip without one reads as pasted on. A clip-path trims that shadow at the base, because the arrow paints above the content and the shadow would otherwise darken the content's interior. It is one wide, faint layer, matching what the content's shadow leaves along the edge the tip grows out of — a tighter layer muddies a shape this small. The offsets are symmetric, since Radix rotates the arrow's wrapper per side and an offset shadow would swing with it.

    Popover.Content and HoverCard.Content now set position: relative. The arrow's wrapper is absolutely positioned, and the open animation's scale made the content that wrapper's containing block for the length of the animation and no longer — which moved the arrow 1px the moment the animation ended. Positioning the content keeps the containing block the same before, during, and after. Two side effects a consumer can observe: an absolutely-positioned child of the content now anchors to the content, and the content's z-50 now applies to the content itself rather than only being read off it, so the content is a stacking context.

    The border stroke is 1.5px against the content's 1px border. Antialiasing spreads a diagonal hairline across two device rows at partial coverage, so a geometric 1px reads thinner than the content's axis-aligned edge. Measured against that edge at both 1x and 2x, 1.5 is the width that matches it.

    data-slot is popover-arrow and hover-card-arrow. width defaults to 14 and height to 7; Radix adds the measured height to sideOffset, so the tip lands sideOffset pixels from the anchor. Neither part takes asChild, because a swapped element loses the two-layer shape — restyle with className (fill-* for the surface, and target the polyline child for the edge), remembering that the part already sets filter and clip-path.

    Tooltip needs no new part: Tooltip.Content already renders its own arrow.

  • #1405 aebf20c Thanks @cody-dot-js! - Code, Kbd, CodeBlock.Code, and CodeBlock.Title now render translate="no", so a browser translation engine skips the text inside them.

    A translation engine translates the text inside <code> and <kbd> like any other prose. A translated CLI flag, YAML key, env var, or shortcut key is wrong, and the reader copies it anyway. translate is the standard way to mark a subtree an engine must skip, and code content is the clearest place to set it, because code must never be translated.

    The attribute also protects the render. Google Translate replaces each text node with a <font> wrapper, which detaches the text nodes React holds. React then either writes an update into a node the DOM no longer shows — stale text, no error — or calls removeChild on a node that moved and throws NotFoundError, which tears down the root when no error boundary sits above it. translate="no" on the element, or on any ancestor, prevents both, because the engine never enters the subtree.

    Two of the four lock the attribute, and two leave it overridable, on whether the part can ever hold prose.

    Kbd and CodeBlock.Code lock it. A key and a block of code are never translatable, so both omit translate from their props type — passing it is a compile error — and both stamp the attribute after the props spread, so a wider props object cannot carry a value past the type either.

    Code and CodeBlock.Title keep the prop. Code also styles terms that are not code, and CodeBlock.Title takes arbitrary children, so translate="no" is a default rather than a rule: pass translate="yes" when the content is prose.

    import { Code } from "@ngrok/mantle/code";import { Kbd } from "@ngrok/mantle/kbd"; // <code data-slot="code" … translate="no">npm install</code><Code>npm install</Code>; // <kbd data-slot="kbd" … translate="no">K</kbd><Kbd>K</Kbd>; // <code data-slot="code" … translate="yes">dashboard</code><Code translate="yes">dashboard</Code>;

    CodeBlock.Title is included because the title usually names a file — example.ts — and a translated filename names a file that does not exist.

  • #1408 0f50685 Thanks @cody-dot-js! - Fix a crash on browser-translated pages. Button threw DOMException: Failed to execute 'insertBefore' on 'Node' the moment isLoading turned on, which tore down the React root and blanked the page. Badge, Anchor, DataTable.HeaderSortButton, and DataTable.ActionHeader shared the same failure.

    Google Translate wraps each text node in a <font> element, which reparents the original node: React's reference to it stays alive, but its parent is now the <font>. An insertBefore aimed at that node then names a child its parent no longer owns, and the DOM raises NotFoundError. Every affected component rendered a conditional element immediately before bare children, so the first click of any submit button threw before the loading spinner could render.

    Button, Badge, and Anchor now render children inside a span carrying a new data-slot — button-label, badge-label, and anchor-label. Each one is public API: target it to style the label, for example with min-w-0 truncate to clamp a long one. DataTable.HeaderSortButton keeps its screen-reader sort announcer mounted at all times, and DataTable.ActionHeader renders its sticky-column indicator after children.

    One layout note. The label is a single flex item, so Button's and Badge's gap falls between the icon slot and the label, never between two children. A call site that passed an icon as a child instead of through icon loses that gap, and <Button icon={<PlusIcon />}>Create endpoint</Button> is the intended shape. A call site that keeps a trailing hint as a child owns its own spacing.

0.83.2

Patch Changes

  • #1398 60057c6 Thanks @cody-dot-js! - BarChart bars now grow with the category spacing instead of stopping at 24px, so a chart with few categories no longer reads gap-toothed.

    Seven days across a wide card used to paint 24px bars inside a 93px step, which left 69px of air beside every bar. A bar now fills its slot up to a cap of 60% of one step, floored at the old 24px and ceilinged at 64px: those seven bars paint about 56px.

    • A dense chart is untouched. The floor and the fill rule meet at a step of exactly 40px, so every chart whose categories sit closer than that paints the pixels it painted before.
    • The ceiling stops the other extreme. Two categories across a 650px plot would fill 195px each unclamped, and a bar that wide reads as a panel rather than a mark.
    • Grouped series split the band as before, and each bar fills its own slot up to the same cap, so a group tightens rather than outgrowing its category.
    • Horizontal bars follow the same rule on their own band axis.

    The thickness reads the step alone, so the same data at the same width always paints the same pixels, and the curve rises with no jump at either clamp — a row arriving or a container resize can never pop the bars to another width. chart/bar-geometry.test.ts pins the rule and chart/bar-thickness.browser.test.tsx measures the painted bars.

    There is no barSize prop, and this adds none: bar thickness stays a property of the layout.

    Docs: https://mantle.ngrok.com/components/charts/bar-chart#barchartbar

  • #1398 60057c6 Thanks @cody-dot-js! - BarChart.Root, LineChart.Root, AreaChart.Root, and ScatterPlot.Root now paint a decorative chart neutral: every series wears the new --color-chart-decorative fill instead of its categorical slot.

    A decorative chart is the backdrop behind an empty-state message. Its values are not information, so nothing about a mark may read as a series — the fill outranks both seriesSlot and a series' own color, and it reaches the canvas, the legend swatches, and the tooltip strokes from one place. Slots keep resolving underneath it, so a chart that leaves decorative gets its palette back unshuffled.

    The fill is also what keeps an overlaid message readable, with no help from the call site. Empty.Description sits on text-muted, which measures 4.88:1 on a bare light card — 0.38 over the 4.5:1 floor for 14px text, so any tint under that copy spends the headroom. --color-chart-decorative is the darkest neutral step that still clears the floor in all four themes: 4.56:1 in light, 4.93:1 in dark, 9.08:1 in light-high-contrast, and 8.14:1 in dark-high-contrast. chart/decorative-contrast.test.tsx measures every theme, and chart/decorative-paint.browser.test.tsx samples the painted canvas.

    • --color-chart-decorative is a new token, one per theme, aliased to that theme's neutral-200. Redeclare it on a Root to retune one chart — className="[--color-chart-decorative:var(--color-neutral-100)]" — and re-measure the copy against the value you pick.
    • No scrim, no dimming, and no blur. A blur leaves the color under the middle of a mark where it was, and dimming a categorical slot far enough to rescue text-muted erases the chart.

    Every decorative chart picks the fill up with no call-site change. Drop an opacity-* you added to a decorative chart to tame its colors — the fill replaces it, and dimming on top only washes the marks out.

    An interactive chart is unchanged. It carries real data, so it is never a backdrop.

    Docs: https://mantle.ngrok.com/components/charts/bar-chart#decorative-charts

  • #1401 b53e6fb Thanks @cody-dot-js! - Sidebar.Header now stacks rows below its alignment band, so a pinned search row can sit under the product switcher without resizing the AppLayout.Header toolbar.

    --sidebar-header-height did two jobs at once: it fixed the header's height, and it named the band an AppLayout.Header toolbar matches. Stacking a switcher row and a Sidebar.SearchTrigger in the header therefore meant raising the token, which grew the toolbar from 54px to 78px and left neither row on the toolbar's center.

    The header is now a grid whose first track is that token. Its first child centers on the band, every later child takes its own row below, and the header grows to fit. The toolbar keeps matching the switcher row alone:

    <Sidebar.Header>	{/* the first child keeps the band, so the toolbar keeps its 54px */}	<Sidebar.SwitcherTrigger>…</Sidebar.SwitcherTrigger>	{/* its own row below, pinned above the scrolling body */}	<Sidebar.SearchTrigger>…</Sidebar.SearchTrigger></Sidebar.Header>

    Rows after the first stack flush, the way rows stack in Sidebar.Footer — the band's own lower half is the space between them. The second row also inherits the header's gutter, so it lines up with the navigation rows without a copy of Sidebar.Body's padding. The header reserves no scrollbar gutter, so on a platform whose scrollbars take space the row runs wider than the rows inside Sidebar.Body.

    A single-row header with no layout overrides on it renders exactly as before, pixel for pixel. If you already stack two rows in Sidebar.Header, check three things:

    • Delete the --sidebar-header-height override you raised for the taller header. The token is now the first row's band, so a raised value moves the switcher row and the toolbar down together while the header keeps growing for the second row — and everything below the header moves down with it.
    • Keep the aligned row as the header's first direct child. A wrapper around both rows takes the band itself, and a first row taller than the band overflows it — raise the token instead.
    • The header is a grid now, so items-* is what centers the first row on the band (under the old flex column it only moved rows horizontally), a flex property on a direct child (flex-1) no longer sizes it, and a className that replaces the header's display drops the band with it.

0.83.1

Patch Changes

  • #1395 f5a47f4 Thanks @dependabot! - Bump the @ariakit/react runtime dependency from 0.4.35 to 0.4.36.

    Combobox.Root and MultiSelect.Root pass their props to Ariakit's ComboboxProvider, and their prop types are Ariakit's ComboboxProviderProps, so 0.4.36's store changes reach both:

    • The combobox store now separates the input text from the selection. inputValue, defaultInputValue, and setInputValue control the query text. Ariakit marks the older value, defaultValue, and setValue names deprecated — they still work, and code that controls a Combobox.Root query through value / setValue should move to inputValue / setInputValue.
    • The store gained selectOnMove, which selects the active item as the user moves through the list with the popover open. It defaults to false, so no existing call site changes.
    • Combobox.Content and MultiSelect.Content are Ariakit ComboboxPopovers, so three of its fixes reach them:
      • A click on the popover's empty space no longer pulls focus into the popover, which used to leave the arrow keys unable to move through the items.
      • A non-modal popover now honors a consumer's getPersistentElements callback, so interacting with the elements it returns no longer dismisses the popover.
      • An item no longer renders as a tab stop for a frame while the list mounts.
    • A combobox popover now closes across a same-origin iframe boundary — when focus moves from an embedded popup to the ancestor document, or when a pointer interaction happens in an existing sibling frame. Ariakit still ignores a real browser or application window blur.

    Mantle's own API does not change.

  • #1392 ed5b1e6 Thanks @cody-dot-js! - Add appearance to Dialog.Content, so a full-page or edge-to-edge dialog is one prop instead of a class recipe.

    // a card, capped — the default, unchanged<Dialog.Content preferredWidth="max-w-2xl"> // fills the 16px-inset box, still a rounded card<Dialog.Content appearance="full-page"> // fills the whole viewport, square and unbordered<Dialog.Content appearance="full-bleed">
    appearanceWidthViewport insetCorners and border
    "centered"capped at preferredWidth16pxrounded and bordered
    "full-page"fills the box16pxrounded and bordered
    "full-bleed"fills the boxnonesquare, no border
    • Every dialog already sat 16px from each viewport edge. Dialog.Content positions itself inside a fixed inset-4 wrapper, which nothing documented, so filling that box meant discovering preferredWidth="max-w-none" and adding h-full by hand. Going edge to edge meant more: the wrapper takes no props, so the only way out was fixed inset-0 on the content, which escapes the wrapper only because the wrapper happens not to be a containing block for fixed elements. appearance="full-bleed" sets the wrapper's inset directly instead.
    • The three values move together as one decision, because a dialog that fills the viewport has no width left to cap and no corner left to round. preferredWidth therefore belongs to "centered" only — <Dialog.Content appearance="full-bleed" preferredWidth="max-w-lg" /> is a type error rather than a prop that silently does nothing. A computed appearance still typechecks, so a consumer can toggle between appearances from state.
    • "full-bleed" keeps its backdrop. The content opens at zoom-in-95, so a sliver of viewport edge stays uncovered for the length of the animation, and the overlay is what fills it.
    • Dialog.Content now stamps data-appearance with the resolved value, so consumer CSS can target a full-bleed dialog without threading a class through.
    • appearance defaults to "centered", which paints exactly what Dialog.Content painted before, so no existing call site changes.

    Dialog also ships its first test file, covering the three appearances, the width cap, dismissal, and the type-level contract.

    Docs: https://mantle.ngrok.com/components/overlays/dialog#full-page-dialog

0.83.0

Minor Changes

  • #1380 c2f1cb0 Thanks @cody-dot-js! - Add Avatar — a small image representing a person or an account, with a text or icon fallback for when there is no picture, or before one loads. Import from @ngrok/mantle/avatar.

    import { Avatar } from "@ngrok/mantle/avatar"; // an account: a rounded square whose color is derived from its id<Avatar.Root appearance="square" colorSeed={account.id}>	<Avatar.Fallback name={account.name} /></Avatar.Root> // a person: a circle, with the fallback covering the load and any failure<Avatar.Root>	<Avatar.Image src={user.avatarUrl} alt="Jane Doe" />	<Avatar.Fallback name="Jane Doe" /></Avatar.Root>
    • Avatar.Root owns the shape, size and surface. appearance="circle" (default) is a person; appearance="square" is a rounded square for the things people belong to — an account, workspace, or team. Keeping the two shapes distinct is what lets a row showing both stay legible without labels. It is size-7 and shrink-0, so the flex rows an avatar usually sits in cannot squeeze it; className merges last, so className="size-10" wins.
    • colorSeed hashes a stable id into one of 17 swatches and switches the foreground to text-static-white, so a subject keeps its color across renders, sessions, devices and the server with nothing stored. Seed it with an id, not a display name — a rename would otherwise recolor the avatar. Omit it for a neutral surface.
    • Avatar.Image is a plain <img> layered over the fallback — no wrapper library, no loading state. The server renders it, the browser fetches while it parses HTML, and a cached image is on screen in the first frame; a loading one shows initials through it, and a failed one unmounts itself and reveals them again. onError runs first and can preventDefault() to keep the image mounted, and changing src is always a fresh attempt. Its alt is required — stricter than the <img> element, because an avatar is the one image everyone forgets and an <img> with no alt announces its URL. Pass alt="" when adjacent text already names the subject, which is the common case.
    • Avatar.Fallback takes either children (an icon, a monogram) or name, which renders at most two uppercase initials — punctuation stripped, code points kept whole so an emoji-leading name survives, casing locale-invariant so SSR and the client agree. The two are mutually exclusive in the type. Derived initials are aria-hidden, because they abbreviate a name the page already carries beside the avatar; announcing them would read it twice ("A C Acme Corp"). Name the root (role="img" + aria-label) when the avatar is the only thing identifying its subject.
    • Every part takes asChild and stamps data-slot (avatar, avatar-image, avatar-fallback), joining an ancestor-forwarded chain rather than replacing it. Avatar.Root renders a <span>, so it stays valid inside the <button> of a switcher row.

    Sidebar.AccountAvatar and Sidebar.UserAvatar are removed. They were the same component twice, scoped to a place an avatar has no reason to be scoped to — the sidebar — which is why the ngrok dashboard already hand-rolled its own copy of the initials and swatch logic beside them. Migration:

    // before<Sidebar.AccountAvatar accountId={account.id} accountName={account.name} />// after<Avatar.Root appearance="square" colorSeed={account.id}>	<Avatar.Fallback name={account.name} /></Avatar.Root> // before<Sidebar.UserAvatar alt="Jane Doe" />// after<Avatar.Root aria-label="Jane Doe" className="text-muted" role="img">	<Avatar.Fallback>		<UserIcon className="size-4" />	</Avatar.Fallback></Avatar.Root> // before — with a photo<Sidebar.UserAvatar src={user.avatarUrl} alt="Jane Doe" />// after<Avatar.Root>	<Avatar.Image src={user.avatarUrl} alt="Jane Doe" />	<Avatar.Fallback name="Jane Doe" /></Avatar.Root>

    The swatch palette and its hash are unchanged, so accounts keep the colors they already have. Two behavior notes: the person silhouette is gone in favor of any icon you compose (the docs use Phosphor's UserIcon), and data-slot="sidebar-account-avatar" / "sidebar-user-avatar" become "avatar" — update any selector or test matching them.

    Docs: https://mantle.ngrok.com/components/data-display/avatar

  • #1382 2f1d03c Thanks @cody-dot-js! - Command: a search trigger, a stateful DialogRoot, and the ⌘K shortcut.

    Command.SearchTrigger is a new part that makes any control behave like a search field that opens the palette. It renders no DOM of its own — it clones its single child — so presentation belongs entirely to that child and the part never has an opinion about how the trigger looks or how it behaves in a collapsed sidebar rail. The child stays a <button>, not a text field: it opens a modal palette, so it announces "has popup dialog" while looking like a field. Because a search-shaped control invites typing, typing works — a printable keystroke or a paste opens the palette with that text already in Command.Input, so no character is lost.

    It ships the accessibility rather than leaving it to the call site: aria-haspopup="dialog", aria-expanded, aria-controls, data-state, and focus restoration come from the dialog trigger it composes; aria-keyshortcuts announces the ⌘K binding that is usually drawn but never spoken; and it adds no accessible name, so the child's visible label is the name and the two cannot drift apart. Its data-slot="command-search-trigger" joins ahead of the child's own.

    Command.DialogRoot is now a real state owner instead of an alias for Dialog.Root. It owns:

    • The open state, controlled (open / onOpenChange) or uncontrolled (defaultOpen) — unchanged for existing consumers.
    • The query text, which is what makes seeding work: the seed and the open state land in one state update, so Command.Input mounts with the character already in it rather than racing the dialog's mount. Every open starts from a known query — the seed, or empty — so dismissing and reopening never resurrects the previous search. Read it with useCommandDialog().query when your palette does its own matching; Command.Input still accepts value to take the query over completely.
    • The ⌘K / Ctrl+K shortcut, new and on by default (keyboardShortcut). It claims the event (Firefox binds ⌘K to its own search bar), keeps the two platform modifiers distinct so macOS's native Ctrl+K still works, has exactly one owner per window so two palettes never open on one keypress, and yields inside contenteditable hosts and <textarea>s where ⌘K is already bound.

    If you already bind ⌘K yourself, pass keyboardShortcut={false} — otherwise the chord has two owners after this upgrade. Command.SearchTrigger then adds no aria-keyshortcuts, and useCommandDialog().keyboardShortcut reports the same flag so your row can drop its visible ⌘K hint too — the UI never advertises a binding mantle does not own.

    Also new, for triggers that cannot use Command.SearchTrigger at all: useCommandDialog() (open, setOpen, openWithQuery, toggle, query, setQuery, keyboardShortcut), and the pure helpers searchActionFromKeyDown / searchActionFromPaste, which classify a keystroke or paste into a SearchAction — seed, open, or ignore. They handle the cases a hand-rolled event.key.length === 1 check gets wrong: Space seeding a stray space, astral characters counted as two and dropped, composition in an input method editor silently doing nothing, and paste doing nothing at all.

    Command.DialogContent now takes over the dialog's initial focus so the caret lands at the end of a seeded query — Radix's own open-autofocus selects the focused field's whole value, which would make the first keystroke after a seeded open replace the seed instead of extending it.

    In a sidebar, the control it wraps is Sidebar.SearchTrigger — see that component's own release note.

    Docs: https://mantle.ngrok.com/components/navigation/command

  • #1341 6793a7c Thanks @cody-dot-js! - Add invert-theme — a first-class class that renders a DOM subtree in the opposite theme.

    Add the invert-theme class to any element and its subtree renders in the opposite theme of the page: light ⇄ dark and light-high-contrast ⇄ dark-high-contrast. The island is styled by the opposite theme's own definitions (each theme block's selector list also targets .invert-theme subtrees on its pair partner's pages), so every mantle component and color token inside works there — no conditional rendering, no per-component overrides.

    Action required if you pass forceTheme anything other than "dark". forceTheme now belongs on four components, not one. Applying the pair partner means a forced page has the opposite theme's stylesheet live, so the theme class on <html> is what decides which one wins. ThemeProvider, PreventWrongThemeFlashScript, preventWrongThemeFlashScriptContent(), and useInitialHtmlThemeProps all accept forceTheme and pin the class, data-applied-theme, and the pre-paint write to it. If you use forceTheme on MantleStyleSheets, pass the same value to those too — otherwise a user whose stored preference resolves to dark gets <html class="dark"> on a forceTheme="light" page and it paints dark. Before this release forceTheme="light" rendered no link tags at all, so the same page painted light. forceTheme="dark" renders and applies exactly what it did before, so it needs no change. The stored preference is still read and setTheme still writes it, so data-theme and theme switchers are unaffected. See Locking a page to one theme.

    • MantleStyleSheets now applies theme pairs together: the active theme's pair partner also gets media="all", so inverted islands always have their opposite theme's CSS applied. forceTheme renders the forced theme's pair of link tags (forceTheme="light" now renders the dark link).
    • An applied theme now outranks the stored preference in the CSS. Every theme block guards its data-theme selector with :not([data-applied-theme]), so [data-theme="dark"] no longer selects the dark block on a page whose data-applied-theme says otherwise. This is what makes a forced page hold its theme while data-theme keeps the preference for the theme switcher. data-theme on its own still selects a theme, so a consumer who sets it without a provider is unaffected. If you set both attributes yourself to disagreeing values, the applied one now wins.
    • Payload note. Applying the pair partner makes it render-blocking, so a light page now blocks first paint on mantle-dark.css (~16 KB raw / ~4.5 KB gzip) and a high-contrast page on both high-contrast sheets (~30 KB raw / ~8.4 KB gzip combined), whether or not it renders an island. There is no opt-out. mantle.css itself grows only by what islands need — from ~34 KB to ~53 KB raw, and from ~8 KB to ~13 KB gzip: Tailwind's default light palette is materialized in a rule scoped to the island selector, so it never reaches :root and cannot override a consumer's own @theme { --color-<ramp>-<stop>: … }. The flip side is that an island restores mantle's pinned defaults, so a ramp you rebrand applies to the page but not inside an island.
    • Sandbar's floating panel is now an invert-theme island: the bar, its buttons, and any custom composed children all render inverted from the page theme in all four themes — matching the origin design.
    • Documented on the Theme page, including the limitations: portaled floating content escapes the island (pass invert-theme to the floating part's className), theme-variant utilities (dark:*, …) keep page-theme semantics, nesting is idempotent, and shadows flip with the island (shadow tokens resolve at the consuming element, keeping the island's interior coherent).
  • #1341 6793a7c Thanks @cody-dot-js! - Add Sandbar — a floating save bar for unsaved changes.

    Sandbar is a persistent, decision-bearing bar that floats near the bottom edge of the viewport. It surfaces pending state — primarily a form's unsaved ("dirty") changes — and stays until the user resolves it: save or discard. Unlike Toast, which announces something that already happened and leaves on its own, a Sandbar waits for the user's decision. (The name: a sandbar is a bar that blocks navigation.)

    • Compound parts: Sandbar.Root (always-mounted, controlled open prop), Sandbar.Message, Sandbar.Actions, Sandbar.SaveButton, Sandbar.DiscardButton. There is deliberately no error part — a failed save is reported contextually in the form (field message, or a form-level Alert that takes focus), because the bar carries the pending decision, not the outcome of the request
    • Accessible by default: persistent polite + assertive live regions announce opening, pending saves, and blocked navigation; the panel is a role="group" named by the visible message; focus is parked and restored around loading/close transitions; Escape is intentionally inert
    • handleRef receives a SandbarHandle — shake(options?: { announcement?: string }) wiggles the panel (skipped under prefers-reduced-motion) and always announces assertively; wire it to your router's navigation guard
    • Sonner-style interruptible motion: state-driven CSS transitions (a reopen mid-exit retargets smoothly from the panel's current position); the panel slides fully off the viewport edge with a motion-led, late fade — 400ms enter, 200ms exit — degrading to fade-only under reduced motion
    • Data attributes: every part stamps a data-slot (sandbar, sandbar-message, sandbar-actions, sandbar-save-button, sandbar-discard-button) and joins any incoming chain ahead of its own name, so asChild composition accumulates the whole chain instead of clobbering it. The panel also stamps data-state="open" | "closed", which drives the enter/exit transition. No public CSS variables.

    Import it: import { Sandbar } from "@ngrok/mantle/sandbar".

    Docs: https://mantle.ngrok.com/components/feedback/sandbar

  • #1382 2f1d03c Thanks @cody-dot-js! - Sidebar.SearchTrigger: the sidebar's search row.

    A new part for the top of Sidebar.Body (or Sidebar.Header, under the switcher) — the row that opens a search or command palette.

    It is deliberately a navigation row and not a text field. The chrome is a Sidebar.ItemButton's, down to the 28px chip it becomes in the collapsed icon rail, because a search entry point in a sidebar is one of the rows rather than a form control wedged among them. Sidebar.ItemButton and Sidebar.SearchTrigger now share one row-chrome constant instead of repeating it, because "these are the same row" is the contract: a search row whose geometry drifts from the navigation rows below it reads as a foreign control.

    Its shortcut prop takes the keyboard hint — usually <><MetaKey /><Kbd>K</Kbd></> — and reveals it only on hover or keyboard focus, so the resting panel stays quiet. The hint is aria-hidden (the aria-keyshortcuts that Command.SearchTrigger adds announces the chord) and dropped outright in the rail, where the row's label is clipped rather than removed so the button keeps its accessible name as a chip. shortcut and asChild are mutually exclusive in the type: the hint is a sibling this part renders, and a slotted child is cloned rather than wrapped, so there is nowhere to put one.

    The row is styling only — it is not wired to any state. Compose it with Command.SearchTrigger for the dialog wiring and the typing behavior, and with Sidebar.Tooltip so the row keeps a visible label in the rail.

    Sidebar.Tooltip also gained a shortcut prop, which renders the chord after the label. The rail hides a row's own hint along with its text, so the tooltip is where a pointer user can still learn it.

    Sidebar.Header is a fixed height so it can align with an AppLayout.Header toolbar, and that height is sized for one row. If you stack the switcher and the search row there, raise --sidebar-header-height on a common ancestor of both rows — <AppLayout.Root className="[--sidebar-header-height:6rem]">. Setting it on Sidebar.Nav only looks right: custom properties inherit downward, so AppLayout.Header would keep the 4.5rem default and the two rows would stop being center-aligned.

    Docs: https://mantle.ngrok.com/components/navigation/sidebar#sidebarsearchtrigger

  • #1382 2f1d03c Thanks @cody-dot-js! - Sidebar.Trigger gets a tooltip and announces the shortcut it binds.

    Sidebar.Root has bound ⌘B / Ctrl+B since the sidebar shipped, but the trigger told nobody: assistive technology got no aria-keyshortcuts, and pointer users got no label at all — the button is icon-only at every breakpoint, and its name was sr-only.

    Both are fixed. The trigger now stamps aria-keyshortcuts ("Meta+B" on Apple platforms, "Control+B" elsewhere), resolved after hydration like every platform-modifier read: the server renders the non-Apple answer because it cannot know the host, and an effect corrects it. And it renders a tooltip showing label — plus the new shortcut prop, for the visible ⌘B chips (shortcut={<><MetaKey /><Kbd>B</Kbd></>}). Unlike Sidebar.Tooltip, that tooltip is not gated on the rail state, because the trigger has nothing else to read at any width.

    Under Sidebar.Root keyboardShortcut={false}, the trigger omits both the attribute and the chips, so it can never advertise a binding that is not bound. useSidebar() exposes the same keyboardShortcut flag so a custom trigger can be equally honest.

    Sidebar.Trigger now requires a TooltipProvider ancestor, like any Tooltip.Root — and like Sidebar.Tooltip already did. Rendering one without a provider throws. Mount a single provider at your app root, decoupled from the sidebar and the app layout, so the app-wide tooltip delay stays app-wide:

    import { TooltipProvider } from "@ngrok/mantle/tooltip"; <TooltipProvider>	<App /></TooltipProvider>;

Patch Changes

  • #1389 793d82a Thanks @cody-dot-js! - Keep a long Breadcrumb trail on one row: it now scrolls sideways with a faded edge instead of wrapping.

    An app header is one row tall, so the trail wrapping onto a second row pushed the page down and left the current page in the wrong place. Breadcrumb.List is now the scroll container, and its edges carry the same scroll-fade-x mask as Tabs.List — the edge with crumbs beyond it fades out, and the edge with nothing beyond it stays flush.

    The trail starts at its end, because the end is the current page. It scrolls itself there whenever that end moves — on mount, when a navigation swaps the crumbs, and when the row around it resizes — and leaves the reader's own scroll position alone the rest of the time, including while a crumb holds focus. Tabbing to a crumb the trail has scrolled past stays the browser's own job; the list carries scroll-padding the width of the fade zone, so that crumb lands clear of the fade rather than under it.

    Two supporting changes make that work in a real header:

    • Breadcrumb.Root carries min-w-0, so inside a flex row such as AppLayout.Header the landmark gives up width to the row instead of pushing its siblings out of it.
    • Breadcrumb.Item and Breadcrumb.Separator no longer shrink, so a crumb keeps its label on one line and the trail scrolls instead of breaking a label in two.

    No prop, part, or export changed. Two things follow from the mask, both covered on the scroll fade page: it fades everything Breadcrumb.List paints, its own background and border included, so keep that styling on a wrapper; and it owns the element's mask-image, so a second mask on the same element replaces it.

    To keep the old wrapping behavior, hand the scrollport back:

    <Breadcrumb.List className="flex-wrap overflow-x-visible">

    Without a scroll container the mask has nothing to animate against, so it stays fully opaque and the row wraps as before. See https://mantle.ngrok.com/components/navigation/breadcrumb#overflow.

  • #1390 c117f9e Thanks @cody-dot-js! - Every chart repaints: all eight series colors change, in all four themes. BarChart, LineChart, AreaChart, and ScatterPlot all paint from the same eight --color-chart-* slots. Every slot gets a new value here. The slot count stays at eight. The token names stay the same, so only the values move.

    What was wrong. The palette gates measured adjacent pairs only — chart-1 against chart-2, chart-2 against chart-3, and so on down the order. A chart puts pairs on screen that those gates never looked at:

    • a legend lists all eight slots in one column;
    • a scatter mark can neighbor any other mark;
    • fixed series slots can leave the painted slots non-consecutive.

    Over all 28 pairs the shipped palette fell to ΔE 1.90 under simulated color vision deficiency (CVD) in light and in dark, 1.55 in light-high-contrast, and 1.71 in dark-high-contrast. At 1.90, chart-1 and chart-7 are one color to a deuteranope. A two-series chart on those slots reads as a single series.

    What it is now. Each theme declares its own eight colors. All 28 pairs clear the ΔE 15 floor for full-color vision and the ΔE 8 target under simulated CVD. The worst pair in any theme measures 9.55. The gate suite runs the all-pairs comparison in every theme from here on. It also holds each theme to the contrast that theme measures today, rather than to the flat 3:1 minimum.

    ThemeAll-pairs ΔE, full colorAll-pairs ΔE, simulated CVDLowest contrast
    light7.53 → 15.4751.90 → 9.5803.584:1 → 3.610:1
    dark7.53 → 15.0721.90 → 9.5513.625:1 → 3.652:1
    light-high-contrast6.32 → 15.1451.55 → 9.6173.651:1 → 3.696:1
    dark-high-contrast8.45 → 15.0541.71 → 9.5726.762:1 → 6.763:1

    Contrast rose a little in three themes and held in the fourth, so no theme regressed. No lightness band moved or widened to fit a new color. light, dark, and light-high-contrast keep every slot inside their band. dark-high-contrast keeps the above-the-ceiling waiver it already carried, with its marks between lightness 0.672 and 0.898. A slot also keeps its family across the four themes: the largest hue drift for any slot is 11.32 degrees. Every literal sits inside the sRGB gamut, so no browser clamps a mark to a color nobody measured.

    The overflow gray is the one distance that got worse. Every series past the eighth wears --color-chart-other. In dark, the closest categorical slot to that gray falls from ΔE 4.86 to 3.81 under simulated CVD. The other three themes rise: light to 6.24, light-high-contrast to 8.72, and dark-high-contrast to 3.12. No palette closes the gap. The overflow has to be achromatic so it reads as "not a category", and the dichromacy simulations strip chroma, so a gray always lands near a mark of its own lightness. The gate suite now records each theme's distance, so it cannot shrink further unseen. Keep folding the tail into one "Other" series rather than painting a ninth.

    Slot 6 changes family, from orange to magenta. Orange sits between slot 4 and slot 8 on the hue circle. Deuteranopia pulls those three toward one another, and that span cannot carry three marks. Magenta near hue 334 sits away from both. That one substitution is what buys the all-pairs result. The families in slot order now read: blue, green, pink, red-orange, teal, magenta, violet, amber. Slot 4 spans hue 31 in light to hue 43 in dark-high-contrast, so it reads as a red on a white card and as a burnt orange on a black one.

    The slots are bespoke oklch() literals now, not aliases of Tailwind ramp steps. A published ramp step lands where its own designer put it. The ramps cannot supply eight all-pairs-safe colors: they top out at seven in dark and six in dark-high-contrast. Each theme file therefore carries its own eight literals. One consequence for anyone who re-tunes mantle's ramps: a chart slot no longer follows a ramp step. A consumer who redeclares --color-blue-500 no longer moves chart-1. Override the chart token itself instead.

    To retheme one built-in slot, redeclare the token on the chart's Root:

    <BarChart.Root className="[--color-chart-1:var(--color-brand)]" data={monthlySpend} xKey="month">	<BarChart.Bar dataKey="endpoints" label="Endpoints" /></BarChart.Root>

    The engine resolves every token through a probe inside Root, so the declaration wins for that chart and leaves every other chart on the page alone. For one series rather than one slot, pass the color prop:

    <BarChart.Bar dataKey="requests" color="var(--color-brand)" />

    Either way, measure your own color against the eight slots. The gates validate the palette mantle ships, not the colors you put in its place.

    Docs: Chart color tokens.

  • #1390 c117f9e Thanks @cody-dot-js! - A series that sets no shape now wears the glyph paired to its series slot. Unless a consumer picked a glyph by hand, every line, area, and scatter series used to draw a circle. Shape carried no identity of its own. Slot 1 now takes the circle, slot 2 the square, slot 3 the triangle, slot 4 the diamond, and four new glyphs carry slots 5 through 8. Every line, area, and scatter chart gains a second identity channel with no code change — a distinct color and a distinct glyph, on the canvas marks, the hover dots, and the legend keys.

    PointShape grows from four values to eight, one per series slot: "circle", "square", "triangle", "diamond", "triangle-down", "plus", "cross", "star". The four additions:

    • "triangle-down" — the triangle mirrored, apex down.
    • "plus" — a filled Greek cross whose arms are a third of its span, not a stroked line pair.
    • "cross" — that plus turned 45°, so a solid X.
    • "star" — a filled five-pointed star at the classic pentagram notch.

    All four paint on the canvas marks, on the DOM hover dot, and on the legend key. The key's data-shape attribute carries the new values for consumer CSS.

    Every glyph carries the circle's fill area, so no series reads heavier than its neighbors. Each new size solves area = πr² the way the shipped three do: the mirrored triangle keeps the triangle's 1.55r circumradius, the plus reaches 1.189r, the cross reaches the same 1.189r because a rotation keeps the area, and the star's outer radius is 1.673r against an inner 0.639r. On a real canvas the four measure between 0.994 and 1.000 of the circle's ink, inside the band the shipped four already span.

    Two surfaces cannot hold that contract exactly, and both predate this change. The hover dot and the legend key clip a box rather than trace a path, so a polygon there paints its own share of the box — the shipped triangle already paints 64% of the circle's ink, and the star paints 39%. Line and scatter markers stroke a 2px surface ring on the glyph's own outline, and that ring eats inward along the whole perimeter, so a spiky glyph keeps less of its fill: at the default marker radius the plus and the cross keep about half the circle's colored ink, and the star about 44%.

    The pairing reads the series slot. Automatic series take unreserved slots in composition order. A series with seriesSlot={3} wears the triangle from whatever position it registers in. A series with seriesSlot="other" wears the circle. Several series may share one slot. If another channel must tell them apart, give each member its own shape or texture.

    Automatic glyphs follow the current composition. Removing an automatic series closes the gap. When a series must keep one color-and-shape identity as siblings appear or disappear, set seriesSlot.

    An explicit shape still wins. The paired glyph is a default, not a policy. A chart that already sets shape on every series paints exactly as it did before.

    Bar series are unchanged. A bar encodes identity by texture rather than by glyph, and texture stays opt-in — nothing assigns one for you. shape serves the line, area, and scatter marks.

  • #1390 c117f9e Thanks @cody-dot-js! - Add seriesSlot to BarChart.Bar, LineChart.Line, AreaChart.Area, and ScatterPlot.Point for charts that need a fixed visual identity.

    Series receive unreserved slots in composition order. Set seriesSlot to give a series a fixed visual identity. Slots 1 through 8 select the built-in color-and-shape pair. "other" selects the neutral tail treatment. Several related series may share one slot.

    Automatic assignments now derive from the currently composed series. Removing an automatic series closes the gap. Mantle reserves fixed slots before automatic series take the remaining slots.

    The color and shape props now override only their own channels. They do not change slot assignment. When the intent is to select the fourth built-in identity rather than to override only the paint, replace color="chart-4" with seriesSlot={4}.

  • #1390 c117f9e Thanks @cody-dot-js! - Bar Chart: two new fill textures, one per color slot. BarChart.Bar's texture prop takes eight values now — "solid", "hatch", "hatch-reverse", "crosshatch", "perpendicular", "parallel", "grid", "dots" — one for each of the palette's eight validated color slots. Both additions paint on the bars and on the series' legend key. The key's data-texture attribute carries the new values for consumer CSS.

    "parallel" inks one rung per tile along the bar's length: vertical lines on vertical bars, horizontal lines on horizontal bars. It is the exact complement of "perpendicular", whose rung runs across the bar's length instead. Both rung textures flip with the Root's orientation, in opposite senses, so a horizontal chart keeps the two apart.

    "grid" inks both rung directions at once — the orthogonal lattice against "crosshatch"'s diagonal one. It is direction-free: an orientation change leaves the lattice identical. Two rung directions ink twice over, so its rungs are thinner than "perpendicular"'s lone rung; at the rung width a grid tile would read as a darker solid. Both new tiles measure inside the ink-coverage band the five non-solid values before them set, so a textured series never shouts over a solid one.

    Texture stays a redundant identity encoding alongside color, never decoration. The ink is tone-on-tone — a darker step of the series' own fill — so a chart keeps its series apart without color vision, in grayscale print, and under forced colors. Keep it opt-in: leave the first series solid and texture the rest, or texture only the pair that color alone cannot separate.

    Several series may share one series slot on purpose. Set the same seriesSlot on every member, then give each member its own texture. The chart reserves that slot once. Three series with seriesSlot={1} and three textures leave seven slots for their automatic siblings, so ten series paint in categorical colors and none falls to --color-chart-other.

    Four things to know before you reach for it:

    • Every member sets the same seriesSlot. Mantle reserves fixed slots before automatic series take the remaining slots.
    • Give every member a distinct texture. Nothing checks it. Two series may share one slot with the same texture, paint pixel-identically, and draw no warning.
    • A member that drops seriesSlot needs a slot of its own. It takes the next unreserved slot in composition order.
    • One hue across several series reads as one group. Reach for the technique when those series really do belong together, keep the legend on screen, and check the shared token against its neighbors first. Texture is a second encoding on top of a legal color, and it never makes an illegal color legal: a pair under the colorblind-safe floor stays illegible whatever the fill.

    Eight series or fewer is still the default advice — rank by the window's total, keep the top seven, and sum the rest into one "Other" series. A shared slot is the narrow exception, for series that really do belong together.

    Docs: Textures and Chart color tokens.

  • #1387 69d6c7b Thanks @cody-dot-js! - Fix Choice publishing none of its per-part documentation, so hovering Choice.Root in an editor showed nothing.

    Each Choice part carries a summary, a @see link to its docs anchor, and an @example in the source, and none of it reached the published types. The namespace object was declared with an explicit type annotation — const Choice: { Root: typeof Root; … } = { … } as const — and the build emits the annotation in place of the documented object literal. dist shipped a bare member list. Choice was the only namespace in the package written that way, so every other compound component already documented its parts here.

    The annotation is gone. as const alone emits the same named member types and keeps the documentation, and it restores the readonly modifiers the annotation was discarding: readonly Root: typeof Root instead of Root: typeof Root. The parts, their props, and their runtime behavior are unchanged.

    COMPONENT_SPEC.md asks for the annotation when a member's type comes from a third-party namespace, and it now records this cost, because the published types are the only channel that feeds editor tooltips.

  • #1371 0d9bf76 Thanks @dependabot! - Bump the @tanstack/react-virtual runtime dependency to 3.14.9.

  • #1378 4b74f6c Thanks @cody-dot-js! - Fix Sidebar.Tooltip popping a label when the panel collapses, with no pointer anywhere near the row.

    The part gated its Tooltip.Content on the collapsed rail state but left Tooltip.Root to manage its own open state — and Radix's pointer close path lives inside the content, which tracks the pointer leaving the trigger. With the content withheld, nothing could close it: the pointer crossing an expanded row on its way to the collapse trigger latched that row's label open, invisibly, and collapsing the panel then mounted every latched label at once. The app shell demos put the account switcher row right beside the Sidebar.Trigger, so a tooltip appeared on nearly every collapse. The same stale open state pointed the row's aria-describedby at an id that was not in the document and left data-state="instant-open" on rows in the expanded panel.

    A row wrapped in a menu or dialog trigger latched the same way without any hover: closing the overlay restores focus to the row, and Radix opens a tooltip on focus. In the shell demos that is the account switcher, which is why collapsing right after using its menu showed the account's name in the rail.

    The rail state is now a veto on a controlled open state rather than a gate on the content, and a rail toggle resets it, so:

    • the expanded panel and the mobile sheet keep every row at data-state="closed" with no aria-describedby;
    • collapsing or expanding dismisses the label instead of replaying a stale one — including the reverse case, where a label shown in the rail survived an expand and reappeared on the next collapse;
    • the label closes when the pointer leaves the row. disableHoverableContent moves that close onto the trigger, which is mounted at every rail state, and a rail label has nothing to hover into — per the WAI-ARIA tooltip pattern it holds no interactive content.

    Opening is unchanged otherwise: the pointer entering a row in the collapsed rail, or focus reaching it, still shows the label. Tooltip.Root still stays mounted at every rail state, so toggling the rail never drops focus off the row the user was on.

  • #1386 9db190a Thanks @cody-dot-js! - Fix BarChart rendering on stacked charts whose series are zero on most columns — the shape a "usage by provider" or "usage by access key" chart produces. Five defects, all of which got louder as the series count grew.

    The rounded cap went to the last-registered series, not to the segment on top. rounded came from seriesIndex === specs.length - 1, computed once per series outside the per-column loop. A value of exactly 0 is not a gap — it stacks with lower === upper — so the last series pushed a zero-extent rect carrying the column's only rounded corner, and the renderer dropped it for having no height. Two columns with pixel-identical silhouettes rendered one square and one capped, decided by which alphabetically-sorted series happened to have data. computeStackBoundaries now records the data end of each column's positive and negative pile, and the cap goes to every segment reaching that end within a pixel. Pixel distance settles the sub-pixel case too: a series contributing a fraction of a pixel paints no ink, so it can no longer take the cap away from the segment the column actually shows.

    The 2px surface gap carved against nothing. The old gate asked only seriesIndex > 0, which never asked whether a segment painted below. With sparse data the first painted segment routinely sits at a nonzero series index with a stacked lower boundary still at 0, so the whole column lifted 2px off the zero baseline and showed the grid through the seam. The gate is now the zero pixel: a column whose stack below rounds to no ink stays welded to the axis.

    The gap also erased segments shorter than 2px. Subtracting the full gap from a 1px segment pushed its baseline edge past its data edge, and the renderer drew the inverted rect on the wrong side of its own boundary — or dropped it outright when the two edges met. Sub-cent contributions against a dollar-scale axis land in that regime constantly. The carve now takes at most half the segment, so a taller segment always paints taller.

    Diverging stacks capped the wrong end. computeStackBoundaries stores a negative segment inverted, so reading lower as the baseline side put the cap, the enter reveal, and the gap on the edge facing zero. Each segment now picks its ends by pile.

    orientation="horizontal" had all four defects verbatim. Both orientations now share one pure stackedSegmentEdges helper instead of mirrored copies.

    Alongside those, three fixes to how a bar chart reads under the pointer:

    • The hover band no longer washes over its neighbors. It drew at max(step, 24)px while the hit region is exactly one step, so above roughly 43 categories the highlight reached into the next bar — at 60 categories, a third of the way — and at the last category it spilled past the plot edge. The band now covers the same span the hit test inverts, and it centers itself when a dense axis drives the step below a pixel.
    • A stacked segment resolves by containment. onDatumActivate's dataKey came from the nearest cumulative boundary, which is right for a stacked line or area, where the mark is the boundary. For a bar the mark is the filled span, so the half of every segment nearer the baseline reported the series below it.
    • A tooltip no longer covers the x-axis tick labels. The vertical clamp reached the bottom of the plot wrapper, which is the row the axis band reserves for its labels. The clamp now holds the readout inside the plot rect and falls back to the wrapper's top edge only for a readout too tall to fit.

    Finally, an all-zero bar or area chart keeps its baseline at the axis minimum. Nicing a flat domain pads it to [-1, 1], which floated the zero line through the middle of the plot on a chart whose minimum is documented as fixed at 0 — visible on any "no usage yet" card.

    Two additions cover all four chart families:

    • The three hover layers now carry a data-slot. <family>-crosshair, <family>-hover-band, and <family>-markers join the slots the Root already stamps, so a consumer restyles a layer through the slot instead of a structural child selector. Each family's docs page gains a data-attribute table listing the Root's whole slot inventory. The layers mount on every kind, and the engine writes their geometry as inline styles — restyle color and border, and leave the transform alone.
    • The docs describe paint order precisely. The store keys a series' paint position to its dataKey on first registration. Series that mount together register in composition order, but a series that mounts later paints last whatever its position in the JSX. A dataKey that returns resumes its original position, and reordering the parts of a mounted chart restacks nothing. Nine sentences across the four pages and their JSDoc twins said otherwise.

    Nothing here changes a prop or an export. Grouped bars, single-series bars, and the line and scatter families keep their geometry.

  • #1385 6e679f6 Thanks @cody-dot-js! - Rewrote comments and JSDoc across the library against the new CONVENTIONS.md § Writing standard. This is a prose change: no component behavior, API, prop, type, or class name moves. The rewritten JSDoc ships in the published .d.ts, so consumers see it in editor tooltips.

    Seven JSDoc blocks were wrong before this pass, so their published text changes meaning:

    • Select.Value now names the case where the styling restriction bites: under Select.Content position="item-aligned", Radix measures this element's box to align the open list over the selected item. The old text gave the instruction with no reason.
    • Table.Root names what it does — it draws the border and rounded corners, and scrolls wide content horizontally with a fading scroll edge. The old summary called that "additional functionality".
    • Select.Item points at onValueChange instead of the deprecated onChange.
    • Combobox.ItemValue says that value and userValue override the defaults only when you pass them.
    • TooltipProvider restores the one-instance rule and names what a second provider actually changes: it applies its own timing props to its subtree, and it tracks skipDelayDuration separately, so a pointer that moves between the two waits the delay again.
    • useBreakpoint states what it returns — the largest matching Tailwind breakpoint, or "default" below 2xs — rather than narrating useSyncExternalStore.
    • useIsBelowBreakpoint states what it returns: true while the viewport is narrower than the given breakpoint.

    Several summaries that restated their own identifier now name what the part does that a sibling does not. Card.Header, Card.Footer, Card.Title, Command.Root, Command.Input, Command.List, Command.Empty, Command.Group, Command.Item, Combobox.Root, MultiSelect.Root, Tabs.Root, Tabs.Badge, AlertDialog.Root, Table.Root, and Theme are the visible ones, and each docs-page copy moved with them.

    Tabs.Root and Combobox.Root state what they own rather than which parts read it. The earlier wording named the parts that read the context, which under-reported the reach: Tabs.Content carries data-orientation through Radix's own context, and Combobox.ItemValue and Combobox.Group read the ariakit store through ariakit. Tabs.Root now names the data-orientation and data-appearance attributes it stamps, which any descendant can select against.

    Four summaries no longer read as a copy of a sibling's:

    • IconButton leads with what makes it an icon button — one icon and nothing else, with the required label prop carrying the accessible name. It opened with Button's three sentences verbatim.
    • Switch names its difference from Checkbox: no indeterminate state. The two shared one sentence.
    • Icons, Pagination, and Theme say what their module exports. Each published only "Re-exports for the … component."
    • Alert.Icon was missing a verb, and its three copies disagreed three ways.
  • #1378 4b74f6c Thanks @cody-dot-js! - Modernize the Sidebar and AppLayout class strings to Tailwind v4 syntax. The compiled CSS is unchanged — these are the shorthands v4 added for what the bracket forms already expressed:

    BeforeAfter
    w-[var(--sidebar-width,16rem)]w-(--sidebar-width,16rem)
    m-[var(--app-layout-card-gutter,0.5rem)]m-(--app-layout-card-gutter,0.5rem)
    group-data-[hydrated]/sidebar-nav:group-data-hydrated/sidebar-nav:
    group-has-[[data-slot~=sidebar-header]]/…group-has-data-[slot~=sidebar-header]/…
    [border-radius:0.625rem]rounded-[0.625rem]
    [&>:first-child]:size-7*:first:size-7

    The CSS variables, their fallbacks, and the selectors they compile to are identical — the variable shorthand carries its fallback (w-(--sidebar-width,16rem) still emits width: var(--sidebar-width,16rem)), and both :has() forms match the same data-slot token. Only the class strings on the elements changed, so update any test or stylesheet that matches them literally.

0.82.1

Patch Changes

  • #1375 e57524a Thanks @cody-dot-js! - Fix six defects and restructure AppLayout, from migrating the ngrok dashboard onto the app shell (#1374). Every one of these failed silently, which is what made them expensive.

    AppLayout is restructured, and this is breaking. The card now reads like every other mantle surface — Content wrapping Header + Body, the same shape as Sidebar.Nav, Dialog.Content, Sheet.Content, and Card.Root:

    AppLayout.Root                        AppLayout.Root├── AppLayout.Notice                  ├── AppLayout.Notice└── AppLayout.Body          ─────►    └── AppLayout.Workspace    └── AppLayout.Inset                   └── AppLayout.Content        └── AppLayout.Content                  ├── AppLayout.Header            └── AppLayout.Header               └── AppLayout.Body

    Migration, in order:

    1. AppLayout.Body → AppLayout.Workspace (the row holding the rail beside the card). Same classes, and data-slot="app-layout-workspace".
    2. AppLayout.Inset is removed. Its child moves up to be a direct child of AppLayout.Workspace; the card gutter it owned is now AppLayout.Content's own margin, driven by the new public --app-layout-card-gutter (default 0.5rem). The gutter has to belong to the card — the only other element that could hold it also contains the rail, so padding there pushed the sidebar off the window edge and broke the flush collapsed icon rail.
    3. AppLayout.Body is now the page region, and the shell's only scroll container. Render your <Outlet /> here, and compose Main onto it via asChild instead of onto AppLayout.Content — SkipToMainLink focuses #main, so the landmark has to be the scrollport or arrows, Space, and PageDown do nothing after a skip. data-slot="app-layout-body" now names this element.
    4. AppLayout.Header renders a <div>, not a <header>, and is no longer sticky. With Main on AppLayout.Body, a <header> here would have no sectioning ancestor and would therefore become the ARIA banner landmark. Compose your own element with asChild if you need one.

    There is deliberately no Footer part. A bar pinned to the bottom of the card is almost always a page's own — an editor's validity line, a save bar — and a route rendering through an <Outlet /> inside AppLayout.Body could never reach a shell-level one, because it would be a sibling of the region the route renders into. The docs show how to pin one inside the page region with three flex rules instead.

    Fixes:

    • AppLayout.Content is now a containing block (relative). It was a scroll container but not positioned, so absolutely-positioned page content resolved against AppLayout.Root — fixed inset-0 in a real shell — and painted across the sidebar rail.

    • A page can finally fill the card. AppLayout.Header was sticky but in flow inside a display: block scroll container, so a page using h-full overflowed by exactly the toolbar's height (measured: 54px), leaving a bottom-pinned bar below the fold on every load. The toolbar is now a shrink-0 sibling outside the scrollport, so it is pinned by construction, cannot be overlapped by a page's own sticky top-0, and steals no height. AppLayout.Body is a flex item that is block inside, which is what lets h-full fit the card while mx-auto max-w-7xl still centers — as a flex child, auto margins beat align-items: stretch and that container would shrink-to-fit, getting narrower as the viewport got wider.

    • AppLayout.Content is overflow-clip, not overflow-hidden. hidden is a scroll container that paints no scrollbar: a toolbar wider than the card let Tab scroll it sideways, translating the header and page out of view with no way back.

    • Slot composes a render-prop className or style instead of dropping it. A function className resolved to "" through cx and a function style was overwritten by prop merging, so a react-router NavLink composed through an asChild mantle part silently lost className={({ isActive }) => …} — the row's highlight just never turned on. Slot now composes a function of the same shape, forwarding the child's own arguments to the child's function and merging the resolved value at the usual child-wins precedence, so <Sidebar.ItemButton asChild><NavLink className={({ isActive }) => …} /></Sidebar.ItemButton> works and no longer needs current derived from useMatch for accessibility (NavLink sets aria-current="page" itself). Slot never calls the function, so it needs to know nothing about the argument. Parts that forward asChild straight to a Radix primitive (Tooltip.Trigger, DropdownMenu.Trigger, Dialog.Trigger) still use Radix's own slot, which joins a function className into the class string — nest a mantle-backed part inside those.

    • Sidebar.ItemButton styles its current row from aria-current="page" as well as data-current. The row's visual state and its announced state now come from the same attribute, whichever side sets it — so a composed child that already resolved the match marks itself and gets mantle's own row treatment: <Sidebar.ItemButton asChild><NavLink to="/endpoints">…</NavLink></Sidebar.ItemButton> highlights with no current, no useMatch, and no className. Before this, a NavLink was announced as the current page but looked like every other row unless the parent re-derived the route to pass current, or hand-rolled the active styling in app code. current is unchanged and still sets both attributes; keep using it for rows whose parent knows (a plain Link, a custom matcher).

    • extractSidebarStateCookie no longer throws on a malformed cookie. Its documented contract already said it answers undefined for an unparseable value, but the raw decodeURIComponent call raised URIError on a bad percent-escape — and a Cookie header is client-controlled, so a single corrupt or hostile value (mantle-sidebar-state=%E0%A4%A) failed the server render on every page load until the user cleared it by hand. Cookie reading is now one shared internal helper used by extractSidebarStateCookie, extractThemeCookie, and getStoredTheme, which also fixes a latent truncation bug: the theme readers split on = and so would cut a value at its first =.

    • AppLayout.Root clips instead of hiding. overflow-hidden is a scroll container that paints no scrollbar — the same trap AppLayout.Content was fixed for on this branch, one level up. Anything wider than the frame let Tab scroll the entire shell sideways, and on a frame pinned with fixed inset-0 that is the whole window, with no scrollbar and no gesture to bring it back. AppLayout.Root is now overflow-clip, which is not a scroll container at all.

    • Two AlertCenter.Items sharing an id now throw instead of failing silently. The projection keys one stable host per id, so two concurrently mounted items with the same id rendered both children sets into one host — duplicated, interleaved content. It fails fast with a descriptive message naming the id. Unmounting and remounting the same id is unaffected: a dismissed alert that returns still resumes its original position.

    Additive API:

    • Sidebar.AccountAvatar and Sidebar.UserAvatar accept asChild. Both render a single <div>, which is invalid inside a <button> — pass an empty element (<span />) to swap it. The avatar's own content (the derived initials; the src image or silhouette) renders inside your element.

    • Sidebar.Tooltip takes Tooltip.Content's props. It was a closed { children, label } object, so the tooltip surface it renders was unreachable and side="right" was hardcoded. className, style, ref, data-*, positioning props such as align and sideOffset, and asChild now land on the tooltip; side is a prop defaulting to "right". children stays a required single element.

    • New data-slot hooks: alert-center-bar-wrapper (the presence wrapper that owns data-state), alert-center-dismiss-icon-button, and alert-center-announcer (the role="status" live region). AlertCenter.Bar, AlertCenter.Content, and AlertCenter.DismissIconButton now join an incoming data-slot chain rather than replacing it — note the dismiss button's own slot value replaces the inherited alert-dismiss-icon-button; Alert's data-alert-dismiss attribute is unchanged.

    • Sidebar.Separator, Sidebar.AccountAvatar and Sidebar.UserAvatar join an incoming data-slot instead of being clobbered by it. All three stamped their slot name before spreading rest props, so a consumer-passed or ancestor-forwarded data-slot replaced sidebar-separator / sidebar-account-avatar / sidebar-user-avatar outright and any styling or test selector targeting them silently stopped matching. They now merge the incoming chain ahead of their own name, like every other sidebar part.

    • ⌘B / Ctrl+B respects the platform and text fields. The sidebar shortcut accepted either modifier on every platform, so on macOS Ctrl+B — the native emacs-style "move the caret back one character" that every macOS text field binds — was preventDefaulted and toggled the sidebar instead, including inside embedded editors like Monaco. The modifier is now resolved per platform and the two never substitute for each other, and the shortcut is ignored while focus is in an <input>, <textarea>, <select>, or any contenteditable host.

    Additive:

    • Sidebar.Tooltip labels a row while — and only while — the desktop panel is collapsed to the icon rail. The rail keeps labels in the accessibility tree but leaves a sighted pointer user with an unlabeled icon column. It gates its content rather than its Tooltip.Root, so toggling the rail never drops focus off the row the user was on, and it composes with a menu trigger (DropdownMenu.Root > Sidebar.Tooltip > DropdownMenu.Trigger asChild). Requires a TooltipProvider ancestor, like any Tooltip.Root — it deliberately does not mount its own, so app-wide delay settings still apply.
    • extractSidebarStateCookie / serializeSidebarStateCookie / SIDEBAR_STATE_COOKIE_NAME from @ngrok/mantle/sidebar — the SSR-safe pair for persisting the collapsed state, mirroring extractThemeCookie. A cookie is the only storage the server can read, so it is the only way to render the collapsed state into the initial HTML with no first-frame correction; use it with defaultOpen, not controlled open. extractSidebarStateCookie returns boolean | undefined so a first-time visitor stays distinguishable from someone who deliberately collapsed the rail.

    Docs: https://mantle.ngrok.com/layouts/app-layout — new sections cover sizing a page to the card (with a live editor-page example), scroll restoration with an inner scrollport, and the desktop-only nature of the sidebar-header alignment. The Sidebar and Alert Center pages gain rail tooltips, cookie persistence, the one-level-deep navigation precondition, hoisting AlertCenter.Root above the shell for route-authored alerts, and testing guidance for the alert center's intentionally duplicated title text.

0.82.0

Minor Changes

  • #1338 3bd61ef Thanks @cody-dot-js! - Add the AlertCenter compound component (@ngrok/mantle/alert-center): a single, top-level entry point for one-to-many account alerts and their upgrade CTAs, meant to replace a stack of independent window banners with one severity-colored bar. Alerts are authored as JSX: each AlertCenter.Item composes the normal Alert banner parts as children (real anchors, arbitrary content) and registers its coordination facts (id, intent) with the renderless AlertCenter.Root — mount an item to show it, unmount to dismiss. The children stay in the author's React tree and portal into a stable per-id host the chrome physically adopts (Element.moveBefore where supported), so context providers and error boundaries around an item work, the children's React events bubble to the author's tree, and content keeps its state as it moves between the bar and a row. Dismissing the last alert steers keyboard focus to the page's main landmark instead of dropping it on <body>. Ranking stays deterministic: a stable sort by severity (danger › warning › important › info › success), then arrival order within an intent (sticky per id, so a returning alert resumes its position). AlertCenter.Bar is an always-visible, full-width strip that renders the top item's children inline as authored — icon, title, Alert.Description, and its call-to-action, so it is a single line for a title alone and a two-line banner once a description is composed (matching how the same alert renders as an expansion row; in-data-[placement=bar]:hidden keeps any element out of the bar) — plus a +N more trigger, collapses to nothing when empty (like AppLayout.Notice), redirects keyboard focus to the next control when a focused top alert is dismissed, and claims no ARIA banner landmark (arrivals and re-ranks are announced by a persistent, visually-hidden role="status" live region that AlertCenter.Root mounts). AlertCenter.Content expands the remaining items inline as ranked, full-width banner rows. AlertCenter.DismissIconButton is the per-item dismiss affordance — its presence in an item's children is what makes that alert dismissable. The chrome around each item is stamped with data-placement="bar" | "list" and data-alert-id as documented styling hooks. Also exports the AlertCenterIntent, AlertCenterItemProps, and AlertCenterRootProps types. Docs: https://mantle.ngrok.com/components/feedback/alert-center

  • #1338 3bd61ef Thanks @cody-dot-js! - Add the AppLayout compound component (@ngrok/mantle/app-layout): a viewport-locked application shell that never scrolls itself. AppLayout.Root fills its nearest sized ancestor (merge className="fixed inset-0" to pin a real app shell to the viewport); AppLayout.Notice is a full-window strip above everything for maintenance banners and environment warnings; AppLayout.Body is the flex row where a Sidebar.Nav composes beside AppLayout.Inset; AppLayout.Content is the rounded bg-card card that is the shell's only scroll container (overflow-y-auto overscroll-none) — compose the Main landmark into it via asChild; and AppLayout.Header is the sticky toolbar <header> rendered as Content's first child (a Sidebar.Trigger and breadcrumbs live there; it is h-14 standalone, and when the shell contains a Sidebar.Header it derives its height from the sidebar's --sidebar-header-height token so the two rows stay center-aligned by construction). The shell is otherwise deliberately unaware of any sidebar. All parts support asChild and accept a ref prop. Docs: https://mantle.ngrok.com/layouts/app-layout

  • #1338 3bd61ef Thanks @cody-dot-js! - Add the Sidebar compound component (@ngrok/mantle/sidebar): a composable, collapsible app-navigation sidebar. Sidebar.Root owns the state and renders no DOM (controlled/uncontrolled open + onOpenChange, controlled/uncontrolled openMobile + onOpenMobileChange, and a configurable mobileBreakpoint, default lg); Sidebar.Nav renders the panel — inline on desktop, collapsing to a skinny icon rail (rows become square chips around their leading icons, group labels fade in place with their rows retained so groupings and icon positions match the expanded state, switcher rows collapse to their leading visual — everything stays in the tab order and the accessibility tree) — and a left-side Sheet below the breakpoint; Sidebar.Trigger is an IconButton with aria-expanded/aria-controls that toggles whichever presentation is active from anywhere under the root; ⌘B/Ctrl+B also toggles it (exact-modifier match; single-owner — with multiple mounted roots only the first claimant handles the keypress; opt out with keyboardShortcut={false}). Navigation composes Sidebar.Header/Body/Footer, Sidebar.Group/GroupLabel/List/Item/ItemButton (with current → aria-current="page", group labels wired to lists via aria-labelledby; a row sizes its leading icon and leaves trailing visuals — a menu caret, a count, a status pip — to size themselves, and stays highlighted while a menu opened from it is open), plus Sidebar.SwitcherTrigger, Sidebar.Separator, Sidebar.AccountAvatar (deterministic, WCAG 4.5:1-contrast swatches), and Sidebar.UserAvatar. Sidebar.Body fades its overflowing edges with the scroll-fade-y mask (and hides its scrollbar inside the collapsed icon rail). Also exports the useSidebar hook and the SidebarState and SidebarMobileBreakpoint types. Public CSS variables: --sidebar-width (default 16rem), --sidebar-width-mobile (default 18rem), and --sidebar-width-icon (default 3.25rem) — read with those defaults as fallbacks, so set them on Sidebar.Nav for one sidebar or on any ancestor (:root) to resize every sidebar in an app — plus --sidebar-header-height (default 4.5rem), the header row height that AppLayout.Header also derives from so the two rows stay center-aligned — set it on a common ancestor of both, and --sidebar-row-width, which mantle.css derives at :root as calc(var(--sidebar-width, 16rem) - 1rem): the width of one row in the expanded panel, for surfaces that render outside the panel and so inherit nothing from it. A menu opened from a switcher or item row keeps the expanded row's width in the collapsed icon rail with <DropdownMenu.Content width="trigger" className="min-w-(--sidebar-row-width)">. Docs: https://mantle.ngrok.com/components/navigation/sidebar

Patch Changes

  • #1365 0a17568 Thanks @cody-dot-js! - Correct JSDoc across Calendar, Progress Bar, Progress Donut, Radio Group, and Scatter Plot (component audit sweep). No API changes.

    • ProgressBar and ProgressDonut: the @see links on Root, Indicator, and both namespace members pointed at #api-progress-bar* / #api-progress-donut* anchors that no longer exist on their docs pages. They now resolve to the real #progressbarroot, #progressbarindicator, #progressdonutroot, and #progressdonutindicator API-reference anchors.
    • Calendar: dropped the stale #api-calendar fragment so the @see link points at the component's docs page.
    • ScatterPlot: the Composition tree omitted ScatterPlot.CopyButton, which the component has shipped as a namespace member — the tree now matches the docs page.
    • RadioGroup: added the missing "Rich option layout (Choice)" tree to the Composition example so the JSDoc matches the five layouts documented on the docs page.
  • #1361 bf7fc75 Thanks @dependabot! - Bump runtime Radix UI dependencies: react-dialog 1.1.21 → 1.1.23, react-dropdown-menu 2.1.22 → 2.1.24, react-hover-card 1.1.21 → 1.1.23, react-popover 1.1.21 → 1.1.23, react-progress 1.1.14 → 1.1.16, react-select 2.3.5 → 2.3.7, react-slider 1.4.5 → 1.4.7, react-slot 1.3.1 → 1.3.3, react-switch 1.3.5 → 1.3.7, react-tabs 1.1.19 → 1.1.21, and react-tooltip 1.2.14 → 1.2.16.

    Bump @ariakit/react 0.4.34 → 0.4.35, which fixes native form submission for MultiSelect. A name — passed to MultiSelect.Input directly or flowed from Field.Control — is no longer set on the combobox input, whose value is the typeahead filter text; it is applied to one hidden input per selected value instead. A native form submit now sends the selected values rather than whatever the user typed. Code that read the name attribute off the rendered combobox input (or queried it by name) must target the hidden inputs instead; consumers reading selection through selectedValue / setSelectedValue are unaffected.

  • #1338 3bd61ef Thanks @cody-dot-js! - Main now joins an incoming data-slot chain ahead of its own "main" slot name (previously the chain was clobbered), so it composes cleanly as an asChild child of layout parts (e.g. <AppLayout.Content asChild><Main>…</Main></AppLayout.Content>).

  • #1368 f591fd0 Thanks @forzalupo! - Render Breadcrumb.Page with the same muted foreground color as the rest of the breadcrumb trail instead of emphasizing it with strong text.

0.81.1

Patch Changes

  • #1356 672479a Thanks @cody-dot-js! - Refine Accordion JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Remove the React displayName assignments from Alert's sub-components; the displayName convention has been dropped.

  • #1356 672479a Thanks @cody-dot-js! - Refine Area Chart JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Bar Chart JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Replace a ternary used inside cx() for Calendar's day class names with convention-compliant class composition (no behavior change).

  • #1356 672479a Thanks @cody-dot-js! - Refine Checkbox JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Code Block JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Command JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Description List JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Dropdown Menu JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Field JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Flag JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Icons JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Line Chart JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Media Object JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Pagination JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees.

    Also drop the asChild prop from CursorPagination.PageSizeValue. It never worked — PageSizeValue hard-codes its {pageSize} per page text as children, which overrode the composed child and made Slot throw React.Children.only. It now always renders a span.

  • #1356 672479a Thanks @cody-dot-js! - Refine Radio Group JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Sandboxed On Click JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Sheet JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Split Button JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Table JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Export the TextAreaProps type from @ngrok/mantle/text-area (its index.ts barrel previously dropped the type re-export).

  • #1356 672479a Thanks @cody-dot-js! - Refine Theme JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1356 672479a Thanks @cody-dot-js! - Refine Toast JSDoc and documentation (component audit): correct @see links, complete examples, and align Composition trees. No API changes.

  • #1350 635dbb3 Thanks @cody-dot-js! - Add composable trailing expand and dismiss controls to Alert.

  • #1353 3d92c6d Thanks @cody-dot-js! - Add a decorative prop to every chart Root — BarChart, LineChart, AreaChart, and ScatterPlot — for placeholder and empty-state charts.

    A decorative chart keeps its visual rendering and animation but drops every layer that only makes sense for real data: it is hidden from assistive technology, removed from the tab order, and inert to pointer and keyboard, with no hover band, tooltip, sr-only data table, or live region. This replaces the previous workaround of native inert plus CSS targeting chart internals ([&_[role=application]]:pointer-events-none), which leaked the chart's interaction structure into consumer code and required a misleading accessible name for fake data.

    The accessible-name requirement and decorative are modeled as a discriminated union, so invalid combinations are unrepresentable: an interactive chart (the default) still requires exactly one of aria-label / aria-labelledby and may wire the interaction callbacks, while a decorative chart forbids the accessible-name props and the interaction props (activeIndex, onActiveIndexChange, onDatumActivate) — the chart won't compile with both. decorative communicates intent ("these values are not information") where a disabled flag would be ambiguous, since a chart is not a form control.

    <BarChart.Root data={placeholderUsage} xKey="day" decorative className="opacity-40">	<BarChart.Bar dataKey="value" /></BarChart.Root>
  • #1355 2684706 Thanks @cody-dot-js! - Charts no longer render fractional axis ticks for integer-valued data (MNTL-56).

    When every value backing an axis is an integer — request counts, error totals — the tick step is clamped to whole numbers, so a small domain like [0, 3] ticks at 0, 1, 2, 3 instead of 0, 0.5, 1, …. The clamp follows the data, not the axis part: it covers the value axis in both bar orientations (including the value ticks painted along the bottom of horizontal bars) and linear x axes on line, area, and scatter charts. Any fractional value in the data restores sub-integer stepping, and tickCount remains an approximate hint.

  • #1345 685e5f5 Thanks @dependabot! - Bump runtime dependencies: @tanstack/react-virtual to 3.14.8 and the Radix UI primitives to their latest patch releases (react-dialog 1.1.21, react-dropdown-menu 2.1.22, react-hover-card 1.1.21, react-popover 1.1.21, react-progress 1.1.14, react-select 2.3.5, react-slider 1.4.5, react-slot 1.3.1, react-switch 1.3.5, react-tabs 1.1.19, react-tooltip 1.2.14).

0.81.0

Minor Changes

  • #1340 f5b991e Thanks @cody-dot-js! - Add the canvas chart family: BarChart (@ngrok/mantle/bar-chart), LineChart (@ngrok/mantle/line-chart), AreaChart (@ngrok/mantle/area-chart), and ScatterPlot (@ngrok/mantle/scatter-plot).

    Each is a compound component with a shadcn-style compositional shape (Root, Grid, XAxis, YAxis, series parts, ReferenceLine, Tooltip, Legend) rendered by a shared Canvas 2D engine built for scale: on-demand rAF commits (zero idle CPU), min/max-per-pixel-column decimation past 4 points per device pixel (O(plot-width) redraws at 100k+ points), DOM-overlay hover so pointer moves never repaint the canvas, and motion built for live data — x/y domains glide toward their targets with frame-rate-independent exponential chases (streaming appends read as continuous scrolling, never ease-stop-jump ticks), axis ticks/gridlines fade via per-label alpha instead of popping, and a liveline-style scroll mask dissolves exiting marks through a fade band at the plot's left edge while a sliding window streams (automatic; no prop). prefers-reduced-motion snaps everything.

    ScatterPlot correlates two continuous measures — or three: providing zKey renders a rotatable 3D point cloud on the same Canvas 2D engine (drag to orbit, depth-sorted with gentle perspective, z carried through the tooltip, announcements, and the sr-only table). Hover hits the nearest point within 24px, and rendering degrades gracefully to tens of thousands of points. A dimensions prop (1 | 2 | 3) morphs the 3D cloud onto the x-axis line, the xy plane, or the full cube with the same glide — the interaction behind "watch the clusters separate" explainers. Scatter is an all-pairs chart form: the validated palette caps it at four series.

    Highlights:

    • New design tokens --color-chart-1…--color-chart-8 and --color-chart-other, derived from mantle ramps and validated for colorblind-safe adjacency, normal-vision separation, and ≥ 3:1 surface contrast in all four themes (with Tailwind utilities like bg-chart-1). Color slots are sticky per dataKey, so filtering series never recolors the survivors.
    • Interaction ships with Root unconditionally: snapped crosshair/hover band, an every-series tooltip, keyboard stepping (arrows, PageUp/PageDown, Home/End, Escape) with polite aria-live announcements, a bounded sr-only data-table twin, and datum activation via click or Enter/Space (onDatumActivate), plus controlled activeIndex for linked charts.
    • The data-table twin is machine-readable: date row headers carry exact ISO timestamps in <time dateTime> and value cells carry raw numbers in data-value, so agents scrape precise series data from the DOM. A composable CopyButton part on every family copies the current rows to the clipboard as a machine-stable markdown table (ISO 8601 dates, un-separated numbers, em dash gaps).
    • Configurable point glyphs: shape ("circle" | "square" | "triangle" | "diamond") on ScatterPlot.Point (the marks themselves), LineChart.Line (canvas markers), and AreaChart.Area — a redundant encoding alongside color, sized for equal visual weight. Legend keys and hover dots wear each series' glyph on all three (line keys ride the glyph on a short stroke).
    • Horizontal bars: orientation ("vertical" | "horizontal") on BarChart.Root names the direction the bars point, not an axis. "horizontal" grows bars rightward from a left baseline with categories banded down the y axis (better for long labels or many categories); the value axis, baseline, reference lines, hover band, and "perpendicular" texture all flip to match, and grouping/stacking/keyboard/data-table are unchanged. Defaults to "vertical".
    • Configurable bar textures: texture ("solid" | "hatch" | "hatch-reverse" | "crosshatch" | "perpendicular" | "dots") on BarChart.Bar — opt-in pattern fills (diagonal hatches, perpendicular rungs, offset dot grids), inked tone-on-tone from each series' own color at equal loudness, so grouped and stacked series stay distinguishable without color vision (grayscale print, forced colors). Legend keys wear the same pattern; tiles rasterize at the device pixel ratio so the ink stays crisp.
    • Charts require an accessible name at the type level (aria-label or aria-labelledby).
    • pending holds the previous render at reduced opacity during refetch — no skeleton flash.

Patch Changes

  • #1347 ec09b1d Thanks @cody-dot-js! - Decouple cross-component helper imports so importing one component no longer pulls another component's implementation into the bundle:

    • preventCloseOnPromptInteraction moved out of toast.tsx into its own module. The Dialog/Sheet primitive imported it from the Toast implementation, which dragged sonner (and its import-time CSS injection) into every dialog-only consumer bundle — a dialog-only Vite 8 build shrinks from 117.0 KB to 86.0 KB (−26.5%).
    • iconButtonVariants moved out of icon-button.tsx into its own module, so Calendar (which only needs the classes for its nav buttons) no longer imports the IconButton implementation.

    No public API changes — both helpers were internal.

  • #1346 4dbed4e Thanks @cody-dot-js! - Remove all Component.displayName = "..." assignments. Bundlers (verified with Vite 8 / Rolldown) treat these top-level property assignments as side effects, which pins otherwise-unused components — and their dependencies — into consumer bundles. Dropping them lets dead components tree-shake correctly; e.g. a dialog-only bundle no longer retains the unused Toast components. React DevTools falls back to inferring names from the component function names.

0.80.0

Minor Changes

  • #1339 959ee6d Thanks @cody-dot-js! - BREAKING: Drop React 18 support — @ngrok/mantle now requires react@^19 and react-dom@^19 peers (previously ^18 || ^19).

    • All components use React 19's native ref-as-prop instead of forwardRef. Call sites are unchanged (<Button ref={ref} /> keeps working); exported components are now plain function components, and every public *Props type includes ref via ComponentProps.
    • composeRefs and useComposedRefs now propagate React 19 ref cleanup functions: when a composed callback ref returns a cleanup, the composed ref returns a cleanup that invokes it (refs without cleanups still receive the legacy null write on detach).
    • Removed redundant displayName assignments — component names are inferred from their declarations; intentional DevTools names on compound parts are kept.

Patch Changes

  • #1334 57cfb4d Thanks @cody-dot-js! - Add CenteredLayout.Notice: a full-window-width strip pinned above everything else in the layout — including CenteredLayout.Header — for maintenance notices, environment warnings, and similar app-wide messaging. It renders an unstyled <div> (w-full shrink-0) that collapses to nothing when empty, the same slot contract as the app-layout shell's notice strip, so an app-wide banner composes identically across mantle's layouts. On flows that scroll, merge sticky top-0 z-20 via className to keep it pinned to the window.

  • #1337 758355f Thanks @forzalupo! - Use a neutral action button for informational alert dialogs.

0.79.3

Patch Changes

  • #1328 f5c6bfd Thanks @dependabot! - Bump the @ariakit/react runtime dependency from 0.4.32 to 0.4.34 and move the tailwindcss peer range floor from ^4.3.2 to ^4.3.3.

    The ariakit bump (0.4.34 is a republish of 0.4.33, whose npm artifact shipped without build output) changes how MultiSelect.Content locks page scroll when opened outside a mantle modal: in browsers with scrollbar-gutter support, ariakit now reserves the gutter on the html element instead of hiding body overflow with padding compensation, and its --scrollbar-width CSS variable is only set in the legacy fallback path (Safari < 18.2) — styles reading it should keep a length fallback (var(--scrollbar-width, 0px)). Pages that already set scrollbar-gutter or overflow-y: scroll on html no longer shift when the popover opens. The bump also picks up a Korean IME fix for autoSelect comboboxes and faster component mounts. Mantle's in-modal scroll-lock opt-out (preventBodyScroll defaulting to false inside mantle modals) is unchanged and still required.

    Tailwind 4.3.3 is a bug-fix-only patch (notably, achromatic oklch theme colors no longer shift hue under opacity modifiers); nothing in mantle requires it — the floor tracks the workspace toolchain. Consumers still on tailwindcss 4.3.2 will see an unmet-peer warning until they upgrade.

  • #1331 d8f5690 Thanks @cody-dot-js! - Restore the classic Tabs.List border, drawn by default in the separator color token: horizontal tablists draw a 1px bottom border and vertical tablists draw the matching side border (also the separator token now, previously gray-200). The border is painted on the list's content box so the focus-ring breathing padding doesn't push it past the container edges, it sits just below the active trigger's decoration, and it stays solid to the container edges while scrolled triggers fade under the scroll-fade mask.

    Pass the new hideBorder prop on Tabs.List to remove the border; it is also rendered as a data-hide-border attribute on the tablist element. The pill appearance never draws a border, so hideBorder has no effect there. Tabs.Root now renders a data-appearance attribute for appearance-scoped styling.

    If you previously worked around the missing border by adding your own border-b to Tabs.List, remove it — otherwise you'll render a doubled, two-tone border.

0.79.2

Patch Changes

0.79.1

Patch Changes

  • #1322 33d6214 Thanks @cody-dot-js! - Breaking: ThemeSwitcher is now a compound component — compose ThemeSwitcher.Root, ThemeSwitcher.Trigger, and ThemeSwitcher.Content instead of rendering a single <ThemeSwitcher />. The contentProps prop bag is gone: pass DropdownMenu.Content props (align, side, collisionPadding, className, …) directly to ThemeSwitcher.Content, and IconButton props (appearance, label, className, …) directly to ThemeSwitcher.Trigger. ThemeSwitcher.Content renders ThemeDropdownMenuRadioGroup when no children are given; pass children to own the menu contents. ThemeSwitcherProps is replaced by ThemeSwitcherRootProps, ThemeSwitcherTriggerProps, and ThemeSwitcherContentProps; ThemeDropdownMenuRadioGroup is unchanged. The trigger's data-slot is now theme-switcher-trigger (was theme-switcher) and the popover's is theme-switcher-content dropdown-menu-content — DropdownMenu.Content now joins an incoming data-slot chain ahead of its own slot name instead of being clobbered by it, so [data-slot~="dropdown-menu-content"] selectors keep matching wrapped menus.

    Migration:

    // before<ThemeSwitcher contentProps={{ className: "shadow-2xl", collisionPadding: { right: 16 } }} /> // after<ThemeSwitcher.Root>	<ThemeSwitcher.Trigger />	<ThemeSwitcher.Content className="shadow-2xl" collisionPadding={{ right: 16 }} /></ThemeSwitcher.Root>

0.79.0

Minor Changes

  • #1316 efbf621 Thanks @cody-dot-js! - Breaking: AlertDialog.Root's priority prop is renamed to intent (still required; the value union is unchanged: info | danger). AlertDialog.Action still derives its button tone from the dialog's intent — danger → a danger button, otherwise an accent button (the old "default") — and AlertDialog.Action/AlertDialog.Cancel keep appearance and intent optional (their wrapper defaults are the design; a consumer-passed value still wins). Call sites that passed priority to Action rename it to intent. See the migration guide at https://mantle.ngrok.com/migrations/priority-to-intent-migration.

  • #1316 efbf621 Thanks @cody-dot-js! - Breaking: Alert.Root's priority prop is renamed to intent (still required; the value union is unchanged: danger | important | info | success | warning). The rename is mechanical: <Alert.Root priority="warning"> becomes <Alert.Root intent="warning">. See the migration guide at https://mantle.ngrok.com/migrations/priority-to-intent-migration.

  • #1316 efbf621 Thanks @cody-dot-js! - Breaking: Button and IconButton replace priority with intent, and both appearance and intent are now required props with no defaults — every call site states its visual weight and tone explicitly. Button's tone union is accent | danger | neutral (accent replaces the old default value; danger and neutral are unchanged). IconButton gains the intent axis (accent | danger | neutral) with ghost/outlined tone styling mirroring Button; its pre-intent rendering was neutral-toned, so intent="neutral" is the visual-parity value for existing icon buttons. Buttons emit data-intent instead of data-priority. New type exports from @ngrok/mantle/button: ButtonIntent, ButtonAppearance, and IconButtonAppearance. Migration is mechanical and pixel-preserving with one deliberate exception — follow the agent-ready guide at https://mantle.ngrok.com/migrations/priority-to-intent-migration (old implicit defaults resolve to appearance="outlined" + intent="accent" on Button and appearance="outlined" + intent="neutral" on IconButton; a filled Button that omitted priority becomes intent="neutral", the system's default primary action, instead of the accent tone it used to inherit).

  • #1316 efbf621 Thanks @cody-dot-js! - Breaking (pass-through surfaces): SplitButton.PrimaryAction now omits intent instead of priority from its forwarded Button props (it still renders outlined + neutral; it never accepted the tone prop). DataTable.HeaderSortButton and DataTable.RowExpandButton forward the renamed props: call sites that passed priority to HeaderSortButton rename it to intent (appearance/intent stay optional on both, defaulting to their current ghost + neutral rendering). See the migration guide at https://mantle.ngrok.com/migrations/priority-to-intent-migration.

  • #1316 efbf621 Thanks @cody-dot-js! - Breaking: Toast.Root's priority prop is renamed to intent (still required; the value union is unchanged: danger | info | success | warning), and the public type exported from @ngrok/mantle/toast is renamed from Priority to ToastIntent. See the migration guide at https://mantle.ngrok.com/migrations/priority-to-intent-migration.

0.78.0

Minor Changes

  • #1313 2f5ddc9 Thanks @cody-dot-js! - Add Breadcrumb, hierarchy-path navigation at @ngrok/mantle/breadcrumb following the WAI-ARIA APG breadcrumb pattern: six compound parts — Breadcrumb.Root (a <nav> landmark labeled "Breadcrumb" by default, overridable via aria-label for localization or multiple trails), Breadcrumb.List (an <ol> whose order conveys the hierarchy), Breadcrumb.Item (<li> crumbs), Breadcrumb.Link (a router-agnostic <a> that composes onto framework links like react-router's Link via asChild), Breadcrumb.Page (a <span> with aria-current="page" marking the current, non-link crumb), and Breadcrumb.Separator (a role="presentation" + aria-hidden divider defaulting to a caret icon, replaceable with custom children like a slash). Every part supports asChild. Documented at https://mantle.ngrok.com/components/navigation/breadcrumb. Purely additive — no changes to existing components.

  • #1313 2f5ddc9 Thanks @cody-dot-js! - Add CenteredLayout — mantle's first published layout primitive, at @ngrok/mantle/centered-layout and documented at https://mantle.ngrok.com/layouts/centered-layout. It is a viewport-filling centered page flow for sign-in, sign-up, onboarding, 404, checkout, and other focused full-page states, consolidating the centered "auth shell" duplicated across ngrok's dashboard and login apps. Four parts: CenteredLayout.Root (the min-h-full flex frame), CenteredLayout.Header (an optional semantic <header> utility strip for account chips and close buttons — merge sticky top-0 z-10 via className to pin it to the window while the page scrolls), CenteredLayout.Body (the growing centered region — by convention the fully-styled brand mark is its first child, and the primary content follows as composed JSX; there is deliberately no Logo or Content part because they would own nothing), and CenteredLayout.Footer (a pinned semantic <footer> utility strip outside the centered region). Landmark semantics stay compositional: when the layout owns the document, compose mantle's Main directly in Body and pair a SkipToMainLink as the first child of Root. It owns structure only — app state never enters it; links, brand marks, and landmarks arrive as composed JSX. New export, purely additive: no impact on existing consumers.

  • #1313 2f5ddc9 Thanks @cody-dot-js! - Add ThemeSwitcher and ThemeDropdownMenuRadioGroup at @ngrok/mantle/theme-switcher — the canonical picker UI over mantle's theme system (ThemeProvider + useTheme). ThemeSwitcher is a compact standalone picker: a ghost IconButton trigger (overridable appearance and accessible label props) with an SSR-safe icon — a Skeleton until hydration, then AutoThemeIcon matching the applied theme — that opens a DropdownMenu of the five themes. ThemeDropdownMenuRadioGroup is the five-theme DropdownMenu.RadioGroup it hosts, embeddable in any existing menu (e.g. an account-menu Theme submenu) so long as it renders inside a DropdownMenu.Content or DropdownMenu.SubContent. Documented at https://mantle.ngrok.com/components/forms/theme-switcher. This consolidates the near-identical theme-switcher copies across ngrok's apps (dashboard, login, docs site) onto one shipped, tested component.

Patch Changes

  • #1313 2f5ddc9 Thanks @cody-dot-js! - Docs-only: correct Breadcrumb.Separator's JSDoc to describe the actual default children — the size-3.5 Icon-wrapped caret — after the sizing rework.

  • #1315 f9445f5 Thanks @cody-dot-js! - Add a unified size prop to the button family: Button gains size (xs | sm | md | lg | xl, default md) and IconButton's existing scale grows to match (lg and xl join xs | sm | md). The scale is shared — the same size name always renders the same box height on both components (xs 24px, sm 28px, md 36px, lg 40px, xl 48px) — and the new ButtonSize type is exported from @ngrok/mantle/button. SplitButton.Root also accepts size and drives both of its halves through context, so the primary action and menu trigger always share one height; SplitButton.PrimaryAction deliberately does not accept size. Button size has no effect when appearance="link" (link buttons inherit surrounding typography and have no box to size) and data-size is not emitted for link buttons. IconButton now always emits data-size (previously omitted when size was not passed); Button emits data-size too, and SplitButton.Root's wrapper div gains a data-size attribute. Only the box scales — typography and icon glyph size are the same at every size. Purely additive: the default md renders exactly what Button rendered before. See decisions/2026-07-13-button-size-and-intent-api.md.

  • #1288 9d988f2 Thanks @dependabot! - Bump runtime dependencies: @ariakit/react to 0.4.32, @tanstack/react-virtual to 3.14.6, and the Radix UI primitives to their latest patch releases (react-dialog 1.1.19, react-dropdown-menu 2.1.20, react-hover-card 1.1.19, react-popover 1.1.19, react-progress 1.1.12, react-select 2.3.3, react-slider 1.4.3, react-switch 1.3.3, react-tabs 1.1.17, react-tooltip 1.2.12). The tailwindcss peer range floor moves from ^4.3.1 to ^4.3.2.

  • #1310 21c8e35 Thanks @cody-dot-js! - Update every JSDoc @see link to the reorganized docs URLs. Component docs now live at mantle.ngrok.com/components/<category>/<name> (e.g. /components/actions/button); the old flat URLs permanently redirect, so previously published links keep working. Also fixes a handful of @see links that pointed at pages that never existed (/components/progress, /components/theme-provider, /components/button-group, /components/calendar). No runtime changes.

  • #1288 9d988f2 Thanks @dependabot! - MultiSelect.Content no longer applies Ariakit's body scroll lock when its trigger lives inside a mantle modal (Dialog, Sheet, AlertDialog) — the modal already locks body scroll. Starting with @ariakit/react 0.4.30, the redundant lock snapshotted the body's inline style (including the modal's transient pointer-events: none) and re-applied that stale snapshot on the animation frame after unmount, permanently freezing the page after closing a modal that contained an opened multi-select. Consumers can still override via the preventBodyScroll prop.

  • #1310 21c8e35 Thanks @cody-dot-js! - Fix incorrect RadioGroup JSDoc @example blocks: the change handler is onChange (Headless UI), not onValueChange; the namespace object is not renderable (<RadioGroup.Root>, not <RadioGroup>); and RadioGroup.ButtonGroup renders the radio group itself, so the examples no longer nest it inside RadioGroup.Root — that composition binds the buttons to an inner, uncontrolled group and silently ignores outer value/onChange props. Documentation only, no runtime changes.

  • #1313 2f5ddc9 Thanks @cody-dot-js! - Slot now concatenates data-slot values in DOM order instead of letting the child clobber the composing parent's: the parent part's slot chain comes first, then the child's own (e.g. data-slot="breadcrumb-item breadcrumb-page"). Parts that join their incoming data-slot prop with their own slot name (Breadcrumb and CenteredLayout do, more to follow) accumulate the full chain through arbitrarily deep asChild composition.

0.77.0

Minor Changes

  • #1305 d9fe81f Thanks @cody-dot-js! - feat(mantle): add useDebounce, useLocalStorage, and useSessionStorage hooks

    • useDebounce(value, { waitMs }) returns a debounced copy of a value that only updates after the value stops changing for waitMs milliseconds. The sibling of useDebouncedCallback for values instead of functions.
    • useLocalStorage(key, defaultValue) is SSR-safe, useSyncExternalStore-backed localStorage string state. The setter keeps every same-key hook instance in the tab in sync, cross-tab changes arrive via the native storage event, and corrupt entries resolve to the default instead of throwing.
    • useSessionStorage(key, defaultValue) is the per-tab sibling of useLocalStorage with the same contract, backed by sessionStorage.
  • #1291 aaa0247 Thanks @cody-dot-js! - feat(mantle): add Choice layout primitive

    Choice is a reusable indicator-left / content-right layout for a single choice — a control (checkbox, radio, switch) beside an emphasized title and an optional de-emphasized description. Use it to pair a Checkbox or RadioGroup option with a rich, titled label, or reach for it directly for your own rows.

    import { Checkbox } from "@ngrok/mantle/checkbox";import { Choice } from "@ngrok/mantle/choice"; <Choice.Root name="notify">	<Choice.Indicator>		<Checkbox />	</Choice.Indicator>	<Choice.Content>		<Choice.Label>Email</Choice.Label>		<Choice.Description>Get notified by email.</Choice.Description>	</Choice.Content></Choice.Root>;
    • Pure layout + association: Choice.Root owns the control's id and the description's id and wires them (htmlFor, aria-describedby) without threading ids. Drop your own control into Choice.Indicator — it is cloned with the shared id / name / aria-* / disabled.
    • Choice.Label renders a real <label htmlFor> (use when the Choice owns the labeling); Choice.Title renders label-less text (use when an ancestor — a clickable row, a Headless radio item, or a Field.Label — owns it). Mirrors Field.Label / Field.LabelText.
    • Field interop: inside a Field.Control, the field's id / name / aria-* flow onto the control and the field's aria-describedby is merged (never duplicated) with the Choice's own description. Outside a Field it uses its own generated ids — the two are decoupled; neither requires the other.
  • #1303 65eb57b Thanks @cody-dot-js! - feat(mantle): add List component

    List is a scrollable list of clickable items — the action / navigation counterpart to SelectableList. Compose List.Items inside a List.Root; each is a <button> (onClick) by default or your own element via asChild (e.g. an <a> for navigation), with an optional current accent. Suits account switchers, SSO provider pickers.

    import { List } from "@ngrok/mantle/list"; <List.Root aria-label="Your accounts" className="max-h-80">	{accounts.map((account) => (		<List.Item key={account.id} onClick={() => switchTo(account.id)}>			<List.ItemTitle>{account.name}</List.ItemTitle>			<List.ItemDescription>				{account.plan} · {account.memberCount} members			</List.ItemDescription>		</List.Item>	))}</List.Root>;
    • A non-selecting semantic list: role="list" of role="listitem" items, styled like the MultiSelect popover (bordered, rounded bg-popover; items highlight on hover / selection with an inset, rounded pill).
    • Non-virtualized by default. Swap Root → VirtualRoot (same Item children) to window long lists. @tanstack/react-virtual ships with both list entrypoints; it is small — a few kB gzipped — and does no windowing work until a VirtualRoot renders.
    • Item supports asChild for link/navigation items, current, and disabled. current sets aria-current, so the current item is announced, not just tinted. A disabled asChild item carries aria-disabled, is removed from the tab order, and swallows clicks/activation — though consumers should prefer omitting the href for a genuinely disabled link.
    • Built on a shared internal list primitive (the scroll + item chrome and ARIA wiring behind both List and SelectableList); the primitive itself is deliberately not exported.
    • Keyboard. Items keep their native tab order, and Arrow↑/Arrow↓ / Home / End also move focus between items (skipping disabled ones, no wrap); the focused item lights up with the hover tint instead of a focus ring on the control. Arrow navigation spans a VirtualRoot's full list — jump targets are scrolled into view and mounted before focus moves — though off-screen items still aren't Tab-reachable (the windowing trade-off).
  • #1303 65eb57b Thanks @cody-dot-js! - feat(mantle): add SelectableList component

    SelectableList is a filterable, multi-select grid of checkbox rows — the inline (non-popover) counterpart to MultiSelect / Combobox. Map your data into options once; the list owns filtering and selection. Each row is an APG grid role="row" (aria-selected) whose cells hold a real Checkbox and a Choice-laid-out title + description — the pattern that lets a selectable row carry a real control and its own interactive content.

    import { SelectableList } from "@ngrok/mantle/selectable-list"; const options = accessKeys.map((key) => ({	value: key.id,	label: key.name,	description: key.maskedToken,})); <SelectableList.Root options={options} value={selected} onValueChange={setSelected}>	<SelectableList.Filter placeholder="Filter access keys…" />	<SelectableList.SelectAll>Select all</SelectableList.SelectAll>	<SelectableList.Viewport aria-label="Access keys" className="max-h-80" />	<SelectableList.Empty>No access keys found.</SelectableList.Empty></SelectableList.Root>;
    • Grid, not a checkbox-group. role="grid" / role="row" / role="gridcell" with aria-selected, a real Checkbox in the selection cell, and the title as a Choice.Label naming it — the WAI-ARIA grid pattern for selectable rows with interactive content. The whole row is click-to-toggle (deferring to the checkbox, the label, and any nested control — no double-toggle).
    • Composable rows. Default title + description row, or pass a render-prop child to a viewport and compose SelectableList.Item / ItemTitle (Choice.Label) / ItemDescription (Choice.Description).
    • Filtering. The query is uncontrolled by default (seed it with defaultQuery) or controlled via query/onQueryChange on Root; pass filter for a custom predicate ((option, query) => boolean — the default is a trimmed, case-insensitive substring match over each option's plain text: labelText, or a string label). A label may be any ReactNode; a rich label requires labelText (enforced by the option type) so the filter has text to match.
    • Disabled rows. options[].disabled is the single source of truth — SelectableList.Item has no disabled prop. Disabled rows render dimmed, carry aria-disabled, are excluded from toggling and "select all", and are skipped by keyboard navigation.
    • Non-virtualized by default. Swap Viewport → VirtualViewport (same props) to window long lists. @tanstack/react-virtual ships with every list entrypoint; it is small — a few kB gzipped — and does no windowing work until a VirtualViewport renders. Windowed rows carry aria-rowindex with aria-rowcount on the grid, keeping screen-reader counts true.
    • SelectableList.Empty is an always-mounted polite role="status" live region (visually hidden while options match), so an emptied filter is announced rather than the grid silently vanishing.
    • SelectableList.SelectAll reuses selectAllChecked to drive a tri-state header over the filtered options.
    • Pure helpers filterSelectableOptions, optionLabelText, toggleSelectionValue, and summarizeSelection are exported for custom filtering/selection logic.
    • Built on a shared internal list primitive (scroll + row chrome and ARIA behind both list components).
    • Keyboard. The grid is a single tab stop with aria-activedescendant navigation: Arrow/Home/End move the active row and Space/Enter toggles it. Because focus stays on the collection (never on a row), it works across the full set under VirtualViewport too — arrows scroll off-screen rows into view rather than stopping at the mounted window.

Patch Changes

  • #1306 2e589b4 Thanks @cody-dot-js! - fix: publish a lean package.json — prune custom export conditions and slim the manifest

    The @ngrok/src-live-types custom export condition only exists so workspace apps can resolve live source files during local development, but it was shipping in the published package.json#exports and pointing npm consumers at ./src/... paths that are excluded from the tarball. A new prepack/postpack lifecycle pair now rewrites the manifest while the tarball is created and restores the pristine package.json afterwards:

    • strips all custom (@-namespaced) export conditions
    • collapses { types, import } export entries to plain string targets (TypeScript resolves the sibling .d.ts automatically)
    • drops import conditions that duplicate default (the CSS entries)
    • removes devDependencies and scripts, which consumers never install or run

    Published packages now expose only standard conditions pointing at dist, and each release permanently adds roughly half as much metadata to the npm packument.

  • #1304 5bd39ed Thanks @cody-dot-js! - fix(mantle): scroll CodeBlock.TabList on overflow instead of wrapping

    CodeBlock.TabList now scrolls horizontally with an edge fade (scroll-fade-x plus overflow-x-auto) when its tabs exceed the header width, rather than wrapping the tab strip onto a second row. CodeBlock.TabTrigger gains shrink-0 whitespace-nowrap so each label keeps its intrinsic width under width pressure. The list reserves room (-m-1 p-1) for each trigger's focus ring so the scroll container never clips it. No API changes — consumers who hand-wired this on className can drop the workaround.

  • #1305 d9fe81f Thanks @cody-dot-js! - fix(mantle): hooks bug fixes, JSDoc gaps, and useRandomStableId removal

    • useUndoRedo: calling undo/redo/push multiple times within a single event handler now works correctly. Previously the callbacks read the previous render's stacks, so back-to-back undo calls returned the same snapshot twice and corrupted the history.
    • Remove useRandomStableId. It had no consumers and React's built-in useId is the right tool; the hook also produced different ids on the server and client, making it a hydration-mismatch hazard.
    • composeRefs/useComposedRefs: drop the deprecated MutableRefObject type and internal type assertions; document both with proper JSDoc.
    • Add test coverage for useCallbackRef, useIsomorphicLayoutEffect, useMatchesMediaQuery, usePrefersReducedMotion/getPrefersReducedMotion, useScrollBehavior, useUndoRedo, and useComposedRefs.
  • #1305 d9fe81f Thanks @cody-dot-js! - refactor(mantle): hoist useIsHydrated snapshot functions to named module-level functions and clarify JSDoc; add unit tests covering client and server rendering

0.76.11

Patch Changes

  • #1284 e6c3737 Thanks @cody-dot-js! - fix(mantle): raise Sheet to z-50 so it can stack over Dialog

    Sheet's overlay and content were pinned at z-40 while Dialog (and AlertDialog, and every popover-family primitive) live at z-50. That fixed ordering meant a Dialog always painted over a Sheet regardless of open order — opening a Sheet from inside a Dialog rendered the Sheet behind it.

    Sheet now matches Dialog at z-50. Both render into portals appended to <body>, so with equal z-index the visual stacking follows portal mount order — the most recently opened layer sits on top ("last opened wins"), the conventional modal-stacking behavior. This is the same mechanism that already lets z-50 popovers/dropdowns/selects layer correctly over a Dialog.

    Note: stacking a Sheet over a Dialog now compounds both backdrops (each renders a full-screen bg-overlay backdrop-blur-xs), dimming the layer beneath.

    Component docs and source JSDoc now call out Mantle's shared z-50 floating layer across Dialog, Sheet, AlertDialog, Popover, Tooltip, HoverCard, DropdownMenu, Select, Combobox, and MultiSelect. The JSDoc coverage includes each floating component's root export, compound component namespace export, and floating content export such as .Content or DropdownMenu.SubContent.

0.76.10

Patch Changes

  • #1281 618f59a Thanks @cody-dot-js! - fix(mantle): ease the Accordion trigger's padding in step with the open/close slide

    The open-state pb-2 padding swap (which gives the focus ring its vertical clearance) was applied instantly, so the trigger's bottom padding — and the ring's bottom edge — snapped 16px→8px at the start of opening and back on close, jittering against the content's height animation. The padding now transitions on the same 200ms/ease-out curve as Accordion.Content's height, so it eases in lockstep with the slide. motion-reduce disables the transition.

  • #1282 b8acd99 Thanks @cody-dot-js! - fix(mantle): add a separator color token and use it for all dividers

    Adds a semantic separator color token (bg-separator / border-separator / text-separator, …) that resolves per theme to what Separator already rendered: gray-500/20 (light), gray-600/20 (dark), and black (high contrast). Every divider now points at this single token instead of hard-coding the color:

    • Separator (and everything built on it — DropdownMenu.Separator, Command.Separator, MultiSelect.Separator, Combobox.Separator, Select.Separator, Sheet, pagination) — refactor only, no visual change.
    • Accordion — item dividers now come from divide-y divide-separator on Accordion.Root (previously a per-item border-card-muted border with last:border-b-0); they match the separator color and are drawn by the root.
    • OtpInput.Separator — previously text-muted, now text-separator.

    Consumers can use border-separator / bg-separator / divide-separator for their own dividers instead of replicating the color.

0.76.9

Patch Changes

  • #1279 6f0e022 Thanks @cody-dot-js! - fix(mantle): keep the Accordion trigger's focus ring from overlapping the body

    When an open section's trigger was focused, the ring-4 focus ring painted over the first line of the body because the body sat flush against the bottom of the trigger's box. The open-state trigger now trades 8px of its bottom padding for matching top padding on Accordion.Body, moving that space outside the button box to give the ring vertical clearance. The trigger→body gap stays a uniform 16px and the collapsed trigger keeps its symmetric py-4 rhythm.

0.76.8

Patch Changes

  • #1276 8e176e5 Thanks @cody-dot-js! - Accordion updates — a new Accordion.Body part and an optional type.

    Accordion.Content is split into a Content viewport plus a new Accordion.Body, matching the Content + Body composition of Dialog and Sheet. Accordion.Content is now the zero-padding region that animates open and closed; the section's bottom padding moves to Accordion.Body. Keeping padding off the animating element is what lets a closed section collapse fully — a padded h-0 border-box can't shrink below its own padding — and it gives you a dedicated element to restyle. The accordion no longer forces text-sm on its content or its trigger; both inherit the ambient text size now (set a text class on Accordion.Body or Accordion.Trigger where you need one).

    • Accordion.Content no longer applies its own pb-4 / text-sm. Wrap a section's content in Accordion.Body to keep the default bottom padding:

      // Before<Accordion.Content>Two to five business days.</Accordion.Content> // After<Accordion.Content>	<Accordion.Body>Two to five business days.</Accordion.Body></Accordion.Content>

      Override Accordion.Body's className (for example pb-0 or px-6) to retune the padding; cx resolves last-wins over the defaults.

    Accordion.Root's type prop is now optional and defaults to "multiple". Omitting type is the same as type="multiple": sections open independently and opening one never closes another (and value / defaultValue / onValueChange are typed as string arrays). Set type="single" to opt into the accordion where opening a section auto-closes the previously open one. This is additive — existing call sites that pass type are unchanged — and mirrors the recent Button change where type became optional with a safe default.

0.76.7

Patch Changes

  • #1274 2788217 Thanks @cody-dot-js! - Rebuild the Accordion and graduate it from preview to a stable, production-ready component. It was a thin @radix-ui/react-accordion wrapper; it is now a framework-free disclosure built on plain <div> and <button> elements whose collapsed content stays in the DOM with hidden="until-found", so the browser's find-in-page (⌘F) can discover and auto-expand matches inside closed sections (synced via the beforematch event). Accessibility mirrors a native <details>/<summary>: the item is a role="group" and the trigger a <button aria-expanded>. Supports "single" and "multiple" modes with controlled (value + onValueChange) and uncontrolled (defaultValue) usage. Accordion.TriggerIcon accepts an svg prop to override the default caret.

    Breaking changes:

    • Removed Accordion.Heading. Compose a rich header as a plain flex row with Accordion.Trigger and any sibling controls (e.g. an "Add Rule" button) directly inside Accordion.Item — no heading wrapper and no absolute positioning.
    • Accordion.Root now requires type ("single" | "multiple") and types value / defaultValue / onValueChange per mode (a string in single mode, a string[] in multiple mode). The collapsible prop is gone — single mode is always collapsible (use "" for "nothing open").

0.76.6

Patch Changes

  • #1270 3145538 Thanks @cody-dot-js! - Remove unused dependencies and config.

    • Drop the browserslist devDependency and config key — nothing in the current toolchain reads them. The build uses tsdown with an explicit target, and Tailwind 4 lowers CSS via its bundled lightningcss with its own defaults.
    • Drop the unused date-fns devDependency and peerDependency. Mantle's source never imports date-fns; the calendar's react-day-picker declares date-fns as its own dependency, so consumers no longer need to install it just for Mantle.

    Both packages remain available transitively where they are actually used.

  • #1272 006ad3a Thanks @cody-dot-js! - Vendor the cx class-merge engine and drop the clsx + tailwind-merge runtime dependencies.

    cx is now powered by a vendored, byte-for-byte port of clsx + tailwind-merge (adapted from cnfast), living in src/utils/cx/vendor. Mantle's previous extendTailwindMerge overrides — the em spacing scale (w-em, p-em, …) and the text-mono / text-size-inherit font-size utilities — are now baked directly into the vendored default config, so no extend call is needed.

    • Output is byte-identical to the previous clsx + extendTailwindMerge implementation (guarded by a 3,200+ case parity fixture).
    • Same public API: cx(...inputs) is unchanged ((...inputs: ClassValue[]) => string).
    • Faster, with the largest gains on repeated re-render call sites (interned conflict keys, per-token descriptor caching, and a V8 arg cache).
    • New, additive: cx can also be called as a tagged template — cx`px-2 px-4 ${active && "bg-blue-500"}` — which caches by call-site identity.

    clsx and tailwind-merge are no longer dependencies of @ngrok/mantle. (clsx remains available transitively via class-variance-authority.)

    Export clsx from the @ngrok/mantle/cx subpath alongside cx.

0.76.5

Patch Changes

  • #1263 3703172 Thanks @cody-dot-js! - Add expandable-row support to DataTable for inline, per-row detail panels — restoring the click-to-expand record inspection pattern that the migration off GenericTable lost.

    New API, all driven by native TanStack Table expansion state (no invented state):

    • DataTable.Row gains an optional renderExpanded prop — the primary, ergonomic path. When the row is expanded it renders its data row plus a sibling detail row holding the returned content, so consumers no longer hand-write the Fragment, the getIsExpanded() conditional, or the extra <tr>. It is called lazily (only while open), so collapsed rows never build their panel. DataTable.Row also now sets data-expanded as a styling hook.
    • DataTable.RowExpandButton — an accessible +/− toggle (@phosphor-icons/react Plus/Minus) for a leading columnHelper.display column. Renders a real <button> that sets aria-expanded and (only while expanded) aria-controls, stops click propagation so it never fires a row-level onClick, lets a consumer onClick veto the toggle via event.preventDefault(), and renders nothing when row.getCanExpand() is false.
    • DataTable.ExpandHeader — a narrow <th> for the toggle column, mirroring DataTable.ActionHeader.
    • DataTable.ExpandedRow — the sibling <tr> renderExpanded wraps content in, also exported for full control (custom colSpan, multiple panels, bespoke markup). Spans every visible column, carries the id that RowExpandButton targets via aria-controls, suppresses its top divider so it reads as one block with its parent row, sits on an opaque surface so it coexists with a sticky action column, and exposes data-expanded-content for styling.
    • expandedRowId(row) — the exported helper that derives the shared, stable DOM id used by the toggle's aria-controls and the expanded row's id.

    Add jsonCodeBlockValue and jsonToShikiHtml to @ngrok/mantle/code-block: an ultralight (~1 KB) client-side JSON syntax highlighter whose output is byte-for-byte identical to Mantle's server/build-time Shiki highlighting — without shipping Shiki, any grammar, or WASM to the browser. Because Mantle highlights with a CSS-variables theme, only tokenization needs to happen client-side; the colors already live in CSS. Ideal for rendering a row's underlying object as highlighted JSON inside a DataTable.ExpandedRow with no build-time plugin and no server roundtrip. Multi-line objects and arrays are collapsible by default (foldable, on), reusing the same computeJsonFoldRanges + fold runtime as every other JSON CodeBlock, so the fold markup and behavior are identical.

0.76.4

Patch Changes

  • #1257 7a32ee9 Thanks @cody-dot-js! - Simplify several costly CSS selectors to cheaper, equivalent forms. All changes are behavior-, theming-, and a11y-preserving — no public API changes.

    • CodeBlock fold gutter (mantle.css + decorate-highlighted-html.ts): the gutter-reservation rule no longer probes every line with a per-line relational :not(:has(> .mantle-code-fold-toggle)) (whose style-recalc cost scaled with line count). A single per-<pre> :has() now scopes the rule, content gets the gutter margin by default, and the sparse opener lines are tagged mantle-code-line-opener so CSS can reset theirs. Measured in Chromium on a fold-enabled block, this removes ~0.5 ms of style-recalc at 5,000 lines and ~3.6 ms at 20,000 lines, while keeping HTML payload flat (only opener lines gain a class).
    • Command group heading (command.tsx): the five **:[[cmdk-group-heading]]:* deep-descendant scans are now direct-child [&>[cmdk-group-heading]]:* selectors (cmdk renders the heading as a direct child of the group). Identical specificity, smaller match scope.
    • Command item icons (command.tsx): the brittle [&_svg:not([class*='size-'])] / [&_svg:not([class*='text-'])] substring-attribute scans are replaced with :where()-wrapped defaults ([:where(&_svg)]:size-5 / [:where(&_svg)]:text-muted), matching the Label precedent. Consumer icon size/color classes still override the defaults cleanly, without the substring scan or its false matches (e.g. text-sm).
    • HorizontalSeparatorGroup (separator.tsx): the universal-descendant [&_*:not([data-separator])]:shrink-0 is now a direct-child [&>*:not([data-separator])]:shrink-0. flex-shrink only affects direct flex items, so this collapses an unbounded subtree walk to a single parent check with no rendered difference.
  • #1256 1a48962 Thanks @cody-dot-js! - Button and IconButton now default type to "button" instead of requiring it.

    The type prop is relaxed from required to optional on both components; when omitted it renders type="button", matching the wider React ecosystem (Radix, shadcn, MUI, …) and neutralizing the native <button> accidental-form-submit footgun. This is backward compatible — every existing call site already passes type, so nothing changes for them; omitting type simply stops being a compile error.

    Forms note: because the default is now "button", a Button/IconButton that relies on native form submission must opt in with type="submit".

    When asChild is used, type continues to have no effect and is not forwarded to the child.

  • #1254 b5b72bc Thanks @cody-dot-js! - Stop publishing source maps. The .js.map files were ~58% of the package's unpacked size (and compressed tarball) and embedded the full original source via sourcesContent, despite src/ not being shipped. Dropping them more than halves the published package.

    This affects nothing consumers import at runtime. The typed .d.ts surface (with its JSDoc and @example blocks) and the bundled agent-discovery artifacts (dist/agent.json, dist/llms.txt) are unchanged; only stepping into mantle's original source in a debugger is no longer possible.

0.76.3

Patch Changes

  • #1241 f7be346 Thanks @cody-dot-js! - Update react-day-picker to 10.0.1 (from 9.14.0) and bump the tailwindcss peer dependency to ^4.3.1. The Calendar component already used the v9+ API surface — no removed navigation/focus/event props, formatter or label aliases, or renamed classNames keys — so this is a transparent dependency bump with no public API changes.

  • #1247 846f5bd Thanks @cody-dot-js! - Fix two issues with Checkbox's indeterminate handling:

    • A controlled checkbox toggling through "indeterminate" (e.g. a table "select all" header cycling unchecked → indeterminate → checked) logged React's "changing a controlled input to be uncontrolled" warning. The indeterminate frame now keeps checked a boolean, so the input stays controlled for its entire lifetime.
    • A controlled checked="indeterminate" set on mount did not render the indeterminate visual — two competing effects clobbered each other. The native indeterminate DOM property is now driven by a single effect keyed on the effective checked state, so it renders correctly on first paint and on every update.

    Add selectAllChecked to @ngrok/mantle/checkbox — a helper that resolves the tri-state checked value (true / "indeterminate" / false) for a "select all" checkbox from { allSelected, someSelected } selection counts, ready to pass straight to Checkbox. Encapsulates the correctness-prone tri-state branch every multi-select list reinvents. Also exports the CheckedState type.

  • #1247 846f5bd Thanks @cody-dot-js! - Update the DataTable source JSDoc @example blocks so the regenerated .d.ts, llms.txt, and agent component manifest teach canonical patterns instead of throwaway ones: the two empty states (an Empty hosted in DataTable.EmptyRow — "no data yet" vs. "no results for the active filter", with a Clear filters reset) and pagination via CursorPagination with a page-size dropdown (replacing hand-rolled prev/next Buttons). No runtime API changes.

  • #1249 44ee551 Thanks @cody-dot-js! - Drive the scroll-fade-x edge-fade utility with a CSS scroll-driven animation instead of JS-toggled data-scroll-left / data-scroll-right attributes, and add a matching scroll-fade-y utility for vertical scroll containers. Single-edge variants — scroll-fade-t, scroll-fade-b, scroll-fade-l, scroll-fade-r — are also available for containers that should fade only one edge (e.g. a sidebar that fades content scrolling under a sticky header but stays flush at the bottom). Table and Tabs now render their horizontal edge fades purely in CSS: the Tabs.List effect no longer runs a scroll listener, ResizeObserver, or MutationObserver for the fade (it only keeps a keyboard-focused trigger scrolled into view), and Table no longer writes the fade attributes. Consumers can still pin an edge opaque by overriding --_fade-left / --_fade-right (the animation drives the upstream --_scroll-fade-* vars). Where animation-timeline: scroll() is unsupported (Firefox stable as of mid-2026) the fade is simply absent — content is never clipped.

  • #1248 3a234e0 Thanks @cody-dot-js! - Update Radix UI dependencies to their latest patch/minor releases: @radix-ui/react-accordion to 1.2.14, @radix-ui/react-dialog to 1.1.17, @radix-ui/react-dropdown-menu to 2.1.18, @radix-ui/react-hover-card to 1.1.17, @radix-ui/react-popover to 1.1.17, @radix-ui/react-progress to 1.1.10, @radix-ui/react-select to 2.3.1, @radix-ui/react-slider to 1.4.1, @radix-ui/react-slot to 1.3.0, @radix-ui/react-switch to 1.3.1, @radix-ui/react-tabs to 1.1.15, and @radix-ui/react-tooltip to 1.2.10. No public API changes.

  • #1239 9b00072 Thanks @cody-dot-js! - Surface each component's JSDoc @example blocks in the agent-facing component manifest (/api/components.json), giving agents copy-pasteable canonical usage without a network lookup. Fix two malformed examples caught in the process: Sheet examples used opOpenChange instead of onOpenChange, and SandboxedOnClick's example was missing its closing code fence. Also documents the Field slot-order anti-pattern — help text (Field.Description) renders below Field.Control, not above it.

0.76.2

Patch Changes

  • #1233 fa978b0 Thanks @cody-dot-js! - Refine Empty and Well styling: Empty uses tighter padding (p-6), Empty.Icon is smaller with a neutral color, Empty.Title and Empty.Description adopt smaller sans-serif text with a muted description, and Well uses the muted card border.

0.76.1

Patch Changes

  • #1229 a569ca3 Thanks @cody-dot-js! - Remove the unused @uidotdev/usehooks dependency. It was never imported by any mantle code; consumers no longer install it transitively.

0.76.0

Minor Changes

  • #1227 fbec001 Thanks @cody-dot-js! - fix(mantle): Dialog.Footer renders children in DOM order

    Dialog.Footer previously applied flex-row-reverse, laying its children out right-to-left so the DOM order was the visual mirror of the source. It now uses flex justify-end and renders children in DOM order, matching Sheet.Footer and AlertDialog.Footer. This also makes keyboard tab order follow visual order.

    Breaking visually: footers with two or more children that relied on the reversal will now render in the opposite order. Reverse the direct children of each affected Dialog.Footer (conventionally secondary/cancel first, primary last) to preserve the previous appearance. See the migration guide at https://mantle.ngrok.com/migrations/dialog-footer-dom-order-migration.

0.75.0

Minor Changes

  • #1225 6b22e51 Thanks @cody-dot-js! - feat(mantle): add Well component

    A recessed, inset container that renders like Card.Root but with the base background and an inset shadow, for grouping and de-emphasizing content such as empty states, code samples, and read-only summaries. Supports asChild for polymorphic rendering.

0.74.0

Minor Changes

  • #1221 100debf Thanks @cody-dot-js! - Add the QrCode component — a scannable QR code that encodes its value with the dependency-free uqr library. Compose QrCode.Root with a QrCode.Frame wrapping a QrCode.Pattern, plus an optional QrCode.Overlay for a centered brand logo. Always renders black modules on a white background for scannability in any theme. The quiet-zone margin around the code is configurable via the quietZone prop (defaults to the spec-recommended 4).

Patch Changes

  • #1223 6dc792c Thanks @cody-dot-js! - Update dependencies: @ariakit/react to 0.4.29, @radix-ui/* packages (accordion, dialog, dropdown-menu, hover-card, popover, progress, select, slider, slot, switch, tabs, tooltip), and date-fns to 4.4.0.

0.73.5

Patch Changes

  • #1218 61d638d Thanks @cody-dot-js! - mantleCode templates now support nested fragment interpolation: interpolating another top-level mantleCode value's .code (declared in the same module, or imported from another module) is highlighted inline at build time by the updated @ngrok/mantle-vite-plugins, with correct per-line rendering and no runtime substitution cost. There is no runtime API change — without the plugin, ${fragment.code} continues to fall back to plain string interpolation.

0.73.4

Patch Changes

  • #1207 b9fadca Thanks @dependabot! - Lint cleanup round across @ngrok/mantle. No public-API changes.

    • Checkbox: the underlying <input> no longer receives both checked and defaultChecked at once (React would warn about this). The component now passes exactly one based on whether it's in controlled mode (checked !== undefined). Indeterminate behavior is unchanged for both controlled and uncontrolled usage — the DOM indeterminate flag is still applied via a ref effect.
    • Context providers in Input, CursorPagination, Dialog, Tabs, Select, Toast, and the www navigation context now memoize their value prop via useMemo, and HorizontalSeparatorGroup lifts its constant value to module scope. This prevents unnecessary re-renders of context consumers when the providing component re-renders for unrelated reasons.
    • ThemeProvider: the cross-tab BroadcastChannel listener now uses addEventListener("message", …) instead of assigning to onmessage, matching the modern event API used elsewhere in the file. Cleanup still goes through BroadcastChannel.close().
    • Internal: tightened vi.fn generics in tests, normalized JSDoc tag names (@fileoverview → @file), tightened a few helper JSDocs, and added inline lint-disables with justifications where rules were false-positives (notably button-has-type on <button type={type}> and require-post-message-target-origin on BroadcastChannel.postMessage).
  • #1213 5a2c3f0 Thanks @cody-dot-js! - Breaking: flattened the Command.Dialog.* namespace into top-level members on Command.

    BeforeAfter
    Command.Dialog.RootCommand.DialogRoot
    Command.Dialog.TriggerCommand.DialogTrigger
    Command.Dialog.ContentCommand.DialogContent

    The nested Command.Dialog namespace was a pass-through re-export of Dialog.Root / Dialog.Trigger plus the Command-specific Content slot. The extra namespace level didn't shorten call sites, was inconsistent with every other compound in mantle (which are single-level), and the inferred-literal-of-as const-of-Radix-re-exports shape was brittle against @types/react upgrades. Flat exports type-check cleanly and resolve through stable, nameable types in the generated .d.ts.

    Migration: replace Command.Dialog.Root → Command.DialogRoot, Command.Dialog.Trigger → Command.DialogTrigger, Command.Dialog.Content → Command.DialogContent.

    Other changes in this release (no API impact):

    • Stable default prop references in Alert, Field, and MultiSelect. The default <XIcon /> for Alert.DismissIconButton, the default <QuestionIcon /> for Field.HelpTrigger, and the default [] values for MultiSelect.Root's defaultSelectedValue and MultiSelect.TagValues's lockedValues are now hoisted to module scope so they keep referential equality across renders.
    • Dependency bumps: @ariakit/react 0.4.26 → 0.4.28, @types/react 19.2.14 → 19.2.15, date-fns 4.1.0 → 4.3.0 (peer also bumped to ^4.3.0).

0.73.3

Patch Changes

  • #1203 f248067 Thanks @cody-dot-js! - Add Field.LabelText — static, label-styled text for Field.Item rows that do not caption a focusable form control. Renders a <p> (not a <label>) with the same text-strong text-sm font-medium typography as Field.Label, so read-only rows (owner cards, derived values, system-managed metadata inside sheets and detail panels) compose cleanly alongside real form fields without misrepresenting themselves as control captions. Supports asChild for polymorphic rendering. For rows with a real focusable control, keep using Field.Label so the label-to-control association stays wired.

    <Field.Item name="owner">	<Field.LabelText>Owner</Field.LabelText>	<CredentialOwnerCard owner={owner} />	<Field.Description>The user or service user that owns this API key.</Field.Description></Field.Item>

0.73.2

Patch Changes

  • #1200 c4e968e Thanks @cody-dot-js! - Make Field.Item the single source of truth for the control's name, id, aria-*, and validation. When a control is wrapped in Field.*, all four are owned by the surrounding Field.Item and overwrite anything passed on the child or Field.Control. To override, set the prop on Field.Item itself; to opt out of the contract entirely, render the control without Field.Control. The pit of success is "use Field.* and let it wire everything up."

    Required name on Field.Item, automatic id/htmlFor

    • Field.Item now requires a name: string prop.
    • Field.Control splats the surrounding Field.Item's name and a stable generated id onto its focusable child. Any name or id passed on the child is intentionally overwritten.
    • Field.Label defaults its htmlFor to that same generated id when rendered inside a Field.Item, so labels wire to their controls without a manual htmlFor / id pair (or a separate useId() call). Pass htmlFor on Field.Label to opt out (e.g. when the focusable element is rendered outside of Field.Control).

    This is a particularly tight fit with TanStack Form's field.name:

    <form.Field name="email">	{(field) => (		<Field.Item name={field.name}>			<Field.Label>Email</Field.Label>			<Field.Control>				<Input					type="email"					value={field.state.value}					onBlur={field.handleBlur}					onChange={(event) => field.handleChange(event.target.value)}				/>			</Field.Control>		</Field.Item>	)}</form.Field>

    One policy for overrides

    Field.Control no longer accepts a validation prop, and no longer reads aria-invalid off the child element. Set validation on Field.Item to opt out of inferred error state from rendered Field.Errors / Field.ErrorList. This collapses the previous three policies (child wins for aria-invalid, context wins for aria-describedby / aria-errormessage, Field.Control wins for validation) into one: Field.Item owns it.

    Field.Control around compound widgets (Select, etc.)

    Field.Control now publishes the resolved ARIA props through a new internal FieldControlContext. Select.Trigger consumes it for aria-describedby / aria-errormessage (which Select.Root doesn't forward to its inner trigger), so the natural composition

    <Field.Item name="fruits">	<Field.Label>Fruits</Field.Label>	<Field.Control>		<Select.Root>			<Select.Trigger>				<Select.Value placeholder="Select a fruit" />			</Select.Trigger>			<Select.Content>…</Select.Content>		</Select.Root>	</Field.Control></Field.Item>

    works end-to-end — name lands on Select.Root's hidden form input, and the field's helper / error IDs reach the focusable trigger button.

    Slider now forwards aria-label / aria-labelledby to its rendered thumb(s), and its Field examples pass an explicit aria-label. Slider thumbs are ARIA slider widgets rather than native labelable controls, so Field.Label provides the visible caption while the slider prop provides the accessible thumb name.

    Duplicate error messages

    Field.Errors and toErrorMessages now collapse duplicate validation messages after trimming. This keeps repeated validator output — for example the same Zod message present in multiple TanStack Form validation phases — from rendering the same error twice.

0.73.1

Patch Changes

  • #1198 2c00e7c Thanks @cody-dot-js! - Add toErrorMessages, a first-class helper exported from @ngrok/mantle/field for normalizing TanStack React Form's mixed field.state.meta.errors array into a clean string[] for Field.Errors. Folds together the shapes TanStack Form yields across its built-in validators — plain strings, { message } issue objects (Zod / Standard Schema), thrown Error instances, and the falsy slots Standard Schema can produce — into trimmed, non-empty strings without coupling Mantle to a specific form library.

    import { Field, toErrorMessages } from "@ngrok/mantle/field"; <Field.Errors messages={toErrorMessages(field.state.meta.errors)} />;

    Also exports the FieldError input type ({ message?: string } | string | null | undefined | false) for callers that want to type their own error arrays against the helper's contract.

0.73.0

Minor Changes

  • #1191 e09de2d Thanks @cody-dot-js! - Add Field, a compound layout primitive for a single form field. Pair with the existing mantle <Label> — the component intentionally does not invent a new label primitive. Layout parts plus Field.Description, Field.Optional, and Field.ErrorList support asChild; Field.Set, Field.Legend, and Field.ErrorItem always render <fieldset>, <legend>, and <li> respectively so their core semantics cannot be accidentally removed.

    The default tree is a Field.Group of Field.Items — that's all most forms need. Reach for the semantic Field.Set + Field.Legend only when the grouping itself carries semantic weight (radios, related checkboxes); a <fieldset> around plain unrelated inputs reads as noise to assistive tech.

    Parts:

    • Field.Item — a single form field. Stacks Label, control (Input, Select, Checkbox, etc.), and any Field.Description / Field.Errors / Field.ErrorList siblings vertically with gap-1.5. Renders a plain <div> — no implicit ARIA role, since the <label htmlFor> ↔ control association already provides the right semantics for a single field.
    • Field.Group — the default primitive for stacking multiple Field.Items vertically with gap-4. Pure layout, no semantics.
    • Field.Set / Field.Legend — a semantic <fieldset> + <legend> for grouping a set of related controls under a single accessible name. Use specifically with RadioGroup (one question, many answers) or related checkboxes (preference toggles, permission flags). Skip it for unrelated fields stacked together — Field.Group is the right call there. Field.Legend owns its own mb-1.5 (instead of relying on the parent fieldset's flex gap, which <legend> ignores in browsers) so the legend sits 6px above the next sibling — override with any mb-* utility.
    • Field.LabelRow — horizontal flex container (items-center, gap-1) for pairing a <Label> with a sibling affordance like a help-icon trigger. Clicks on a <label> forward focus to the associated control, so an interactive button can't live inside it — Field.LabelRow is the way out.
    • Field.Help / Field.HelpTrigger / Field.HelpContent — thin wrappers over Popover + IconButton + a default Phosphor QuestionIcon so the help-affordance pattern stays concise while still requiring a contextual accessible label. Popover (not Tooltip) so the affordance is reachable on touch. Field.HelpTrigger carries a default -my-0.5 so the 24px xs button keeps its full click target while contributing only 20px to the Field.LabelRow flex line — matching the label's text line-height so the label is not pushed off-center. Override with any my-* utility.
    • Field.Optional — inline muted "(Optional)" suffix for placement inside the <Label> so screen readers announce it as part of the accessible name.
    • Field.Description — muted body-color hint text. Sits below the control inside a Field.Item.
    • Field.Errors — convenience renderer for string validation messages. Trims messages, filters empty values, and renders a semantic Field.ErrorList / Field.ErrorItem tree without coupling Mantle to a specific form library.
    • Field.ErrorList / Field.ErrorItem — literal semantic <ul role="list"> of <li> validation errors for custom error markup. Empty Field.ErrorItem children collapse so message-less validator results do not produce empty referenced list items.

    Field parts forward refs through React 18's forwardRef API so refs land on the rendered DOM element, including supported asChild substitutions.

    Field.Item auto-generates stable IDs for descendant Field.Description / non-empty Field.Errors / Field.ErrorList parts, then merges those IDs onto known form controls via aria-describedby / aria-errormessage without changing the control's own id or name, so id={field.name} remains the right TanStack Form pattern.

    When a Field.Description follows Field.Errors or Field.ErrorList, the parent's gap-1.5 auto-collapses via -mt-1.5 so the error list and the trailing helper read as a single tight block. Pass any margin utility on Field.Description to override — the rule's specificity is flattened to (0,1,0) so a user class wins.

    Switch now accepts validation and reads the ambient Field.Item / Field.Control state, matching the rest of the mantle form controls (Checkbox, Input, Combobox, MultiSelect, OtpInput, Select, TextArea). Validation-specific styles (data-validation-{success,warning,error}) drive the checked track color and focus ring.

    Field.Control now throws a descriptive error when its child is not a valid React element or a render-prop function, instead of crashing inside Slot with an opaque "expected a single child" message.

Patch Changes

  • #1195 fb7d6f3 Thanks @cody-dot-js! - Bump tailwindcss and @tailwindcss/vite to 4.3.0, and tailwind-merge to 3.6.0.

  • #1193 985659f Thanks @cody-dot-js! - CodeBlock.CopyButton now accepts an optional label prop for the accessible (screen-reader) label, defaulting to "Copy code".

  • #1189 e2c07e5 Thanks @cody-dot-js! - CodeBlock.Code no longer paints a browser-default focus outline on its <pre>, and no longer hardcodes tabIndex={-1} on the element.

    The tabIndex={-1} default was a leftover workaround from when Prism.js auto-injected tabIndex={0} on every <pre> it touched. Prism was replaced with build-time Shiki, so nothing is forcing a tabindex anymore — the override is dead weight that also opted the scroll container out of Chrome's keyboard-focusable-scroller heuristic, hurting keyboard-only users on overflowing blocks. The prop is now passed through cleanly via ...props, so consumers can still set tabIndex={-1} (or any other value) per instance if they want to exclude a block from the tab order.

    The outline-hidden is paired with the tabindex change so the now-optionally-focusable <pre> doesn't paint the browser-default focus ring when scripted focus or scroll-container heuristics put focus on it. Nested controls (fold toggles, copy buttons) keep their own :focus-visible styles.

  • #1185 72b52b1 Thanks @cody-dot-js! - Label now defaults to font-medium when it doesn't wrap an interactive form control, and inherits weight when it does (e.g. <Label><Input /></Label>). The default is applied through a :where()-wrapped selector so the rule's total specificity is (0,0,0) — a user-supplied font-bold / font-normal / font-semibold overrides it cleanly without needing the ! important modifier.

    Detection covers <input>, <textarea>, <select>, <button>, and [contenteditable] descendants, which transitively covers mantle's checkbox/radio (real <input>), select/multi-select/menu triggers (real <button>), and any third-party control built on those primitives. The [contenteditable] attribute selector would normally raise the rule's specificity to (0,1,0) and tie with a bare utility class — wrapping the whole selector in :where() flattens it back to (0,0,0) so user overrides still win.

  • #1194 40cb820 Thanks @cody-dot-js! - Dialog.Body and Sheet.Body now reserve space for the scrollbar via scrollbar-gutter: stable, preventing horizontal content shift when the body grows past its container and a scrollbar appears.

0.72.0

Minor Changes

  • #1174 51a0864 Thanks @cody-dot-js! - Extend code-block folding toward VS Code parity by switching the JS/TS/JSX/TSX, HTML, XML, and CSS strategies to AST / parser-quality matchers, and adding keyword-pair folding for Bash / Shell / Ruby. The runtime click handler is unchanged — all heavier work runs server- or build-side inside @ngrok/mantle-server-syntax-highlighter.

    What now folds (per language):

    • JS / TS / JSX / TSX (oxc-parser) — block statements, object/array literals, class/interface/type-literal/enum bodies, switch statements, multi-line template literals (including tagged), JSX elements (<Foo>…</Foo>), JSX fragments (<>…</>), member-expression element names (<obj.Foo>), and multi-line self-closing opening tags / attribute lists. Single-line self-closing JSX tags still don't get a toggle.
    • HTML / XML (parse5 with sourceCodeLocationInfo) — multi-line elements (<div>…</div>), nested element folds, multi-line opening tags (the attribute list collapses into the tag name), and full HTML documents with explicit <html>/<head>/<body>. XML routes through parse5's SVG fragment context for foreign-content (XML-like) tokenizer rules so <empty/> is correctly self-closing.
    • CSS — single-pass CSS-aware brace matcher (skips /* … */ comments, "…" and '…' strings, and backslash escapes) replacing the previous token-based bracket walk.
    • Bash / Shell — keyword pairs from VS Code's shellscript grammar: if … fi, case … esac, for|while|until|select … do … done, brace-form function bodies. Folds the leading-} brace-group close while ignoring ${VAR} parameter expansions (which never lead a line).
    • Ruby — combines the bracket pass with keyword pairs from VS Code's ruby grammar: def, class, module, begin, if, unless, while, until, for, case, and do |args| blocks, all closing on end. Mid-block keywords (else, rescue, when) deliberately don't pop, so begin … rescue … end is a single fold.

    New exports on @ngrok/mantle-server-syntax-highlighter:

    • computeServerFoldRanges({ code, language, tokens }) — main dispatcher, called by highlightWithMantleShiki.
    • serverFoldStrategyFor(language) / serverFoldNeedsTokens(strategy) — let callers control whether to capture Shiki's includeExplanation: 'scopeName' tokens.
    • computeJsxFoldRanges, computeHtmlFoldRanges, computeCssFoldRanges, computeKeywordFoldRanges — per-strategy computers, individually testable.

    @ngrok/mantle/highlight-utils and @ngrok/mantle/code-block now also re-export finalizeFoldRanges so consumers building custom strategies can reuse the dedup pass.

    Skipping includeExplanation on the AST and raw-source paths (JS/TS/HTML/CSS/JSON) keeps the highlight pipeline cheaper than the previous token-only approach for those languages.

  • #1174 51a0864 Thanks @cody-dot-js! - Extend fold gutters to every supported language, not just JSON. Each language uses the folding model that fits its grammar:

    • AST-based (javascript, typescript, jsx, tsx) — oxc-parser emits folds for blocks, object/array literals, JSX elements/fragments, multi-line template literals, and TypeScript-only bodies.
    • Raw-source (json, css) — single-pass matchers fold braces/brackets while skipping strings, comments, and escapes without requiring Shiki scope explanations.
    • Bracket-paired (go, java, csharp, rust) — multi-line { … } and [ … ] ranges fold via TextMate-scope-aware token walking.
    • Indentation-based (python, yaml) — blocks are detected by leading whitespace; the opener (e.g. def, class, a YAML key) stays visible and child lines collapse.
    • AST-based (html, xml) — parse5 source locations fold multi-line elements and opening tags while honoring self-closing XML tags and HTML void elements.
    • Keyword-paired (bash, shell, sh) — shell blocks fold from VS Code-style keyword pairs (if … fi, case … esac, do … done) plus brace-form function bodies.
    • Bracket + keyword (ruby) — Ruby combines brace/array folds with keyword blocks such as def, class, module, begin, do, if, and case, all closing on end.
    • No folding (plain text) — text-like languages have no block model to fold.

    New helper exports from @ngrok/mantle/code-block and @ngrok/mantle/highlight-utils:

    • computeFoldRanges({ language, tokens }) — generic fold-range computer that dispatches to the right strategy. Consumes Shiki tokens with includeExplanation: 'scopeName'.
    • foldStrategyFor(language) — returns 'bracket' | 'indentation' | 'tag' | 'none' for a supported language.
    • Types: ComputeFoldRangesInput, FoldStrategy, FoldLine, FoldToken, FoldExplanation, FoldScope.

    computeFoldRanges is a low-level token helper for custom integrations; the Vite plugin, MDX code fences, and server highlighter use the richer @ngrok/mantle-server-syntax-highlighter dispatcher for AST, raw-source, and keyword folding.

    The existing computeJsonFoldRanges continues to work for callers that already use it on raw JSON source.

    Also fixes a latent bug where toggling CodeBlock.ExpanderButton would replace the highlighted <code>'s child DOM (because dangerouslySetInnerHTML received a fresh { __html } object on every parent re-render). The replacement was invisible for plain code blocks but silently broke fold toggles, since the runtime's per-<code> geometry cache then pointed at detached line elements. The prop is now memoized so child DOM identity survives unrelated re-renders, and the fold runtime additionally validates that cached lines are still connected before reusing them.

Patch Changes

  • #1174 51a0864 Thanks @cody-dot-js! - Add VS Code-style fold gutters to JSON code blocks. Every multi-line { … } or [ … ] now renders a semantic <button> toggle in the gutter; clicking (or focusing and pressing Enter / Space) hides the inner content while keeping the opener and closer lines visible, with a ⋯ placeholder marking the collapsed region.

    • Fold ranges are computed at build time, so there is zero highlighting cost in the browser.
    • All toggle interaction runs through a single delegated click handler per <pre> — no per-line listeners and no React re-renders. This keeps fold/unfold cheap even on JSON blobs with thousands of lines.
    • New helper exports from @ngrok/mantle/code-block and @ngrok/mantle/highlight-utils: computeJsonFoldRanges(code) and the FoldableRange type.
    • decorateHighlightedHtml accepts a new optional foldableRanges input. When omitted, the decorator behaves exactly as before — no fold gutter is rendered.
    • Server-highlighted code blocks can provide fold ranges for every supported language with a folding strategy; raw JSON callers can continue using computeJsonFoldRanges(code) directly.
  • #1176 5174b31 Thanks @cody-dot-js! - OtpInput.Root now accepts a validation prop ("error" | "success" | "warning" | false, or a function returning one). When set, each group's outer borders and the active focus ring are recolored with the matching validation hue, mirroring the styling contract of Input. validation="error" additionally sets aria-invalid on the underlying input so assistive tech announces the failure state.

    Internal: ProgressDonut now uses the $cssProperties helper for inline CSS-variable styles instead of an as CSSProperties cast — no behavior change.

0.71.1

Patch Changes

  • #1170 5c7ea22 Thanks @cody-dot-js! - Expand the machine-readable surface for AI agents.

    New endpoints on the docs site:

    • /api/package.json — version, peer/dev dependency ranges, and the sorted list of importable @ngrok/mantle/* subpaths.
    • /api/changelog.json — CHANGELOG.md parsed into one entry per version with PR/commit/author metadata broken out by semver bump.
    • /api/search-index.json — flat sorted array spanning components, hooks, and utilities, with keyword tokens derived from each name + summary.
    • /api/schema.json — JSON Schema (draft-07) definitions for every /api/*.json payload.

    Each entry in /api/components.json now also carries a jsdoc field with the first sentence of the source JSDoc on the component's primary export.

    The published package now ships @ngrok/mantle/agent.json and @ngrok/mantle/llms.txt — pointers to the live endpoints above plus the package's own subpath inventory — for agents working without network access.

  • #1168 9da2cac Thanks @cody-dot-js! - Reduce OtpInput.Slot transition duration from 300ms to 150ms for snappier focus feedback.

0.71.0

Minor Changes

  • #1167 acd0c55 Thanks @cody-dot-js! - Add OtpInput, a compound component for capturing one-time passcodes (OTP). Built on top of input-otp, it renders a single hidden input that handles paste, autofill, and IME correctly while displaying each character in its own styled slot. Composes as OtpInput.Root > OtpInput.Group > OtpInput.Slot with optional OtpInput.Separator between groups. Re-exports the REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS, and REGEXP_ONLY_DIGITS_AND_CHARS pattern helpers for restricting accepted characters.

Patch Changes

  • #1166 4f35862 Thanks @cody-dot-js! - Significantly expand JSDoc coverage across the package to improve agentic and IntelliSense-driven discovery. Every index.ts barrel now ships a @see link to its docs page. All hooks (useCallbackRef, useCopyToClipboard, useDebouncedCallback, useIsomorphicLayoutEffect, useMatchesMediaQuery, usePrefersReducedMotion, useRandomStableId, useScrollBehavior, useUndoRedo) now document @param, @returns, and at least one @example. Compound components (Dialog, Sheet, AlertDialog, Tooltip, Popover, HoverCard, Select, Combobox, MultiSelect, DataTable) gained "when to use vs alternatives" guidance on their top-level namespace JSDoc, full-tree @example blocks across sub-components, and explicit notes on required setup (TooltipProvider mount, useReactTable instance for DataTable.Root, etc.). Also removes the empty "./date-picker" export from package.json (it had no implementation and was never importable). No runtime behavior changes.

  • #1161 913acb6 Thanks @cody-dot-js! - Expand JSDoc coverage on Skeleton, Badge, Code, Kbd, MediaObject, Anchor, Flag, Label, PasswordInput, and the top-level DataTable (full layout examples — sortable+filterable+paginated, sticky action column, and clickable-row-with-Link). All follow the same template as the flagship components: a one-line summary, a "When to use / When not to use" block, accessibility notes where relevant, and @examples with imports. No runtime behavior changes — IntelliSense surfaces more guidance for both human contributors and AI agents using the library.

  • #1155 0f0b607 Thanks @dependabot! - Update tailwindcss and @tailwindcss/vite to 4.2.4.

0.70.2

Patch Changes

  • #1152 01b04f4 Thanks @forzalupo! - Clean up CodeBlock copy button placement and wrapper sizing so it aligns consistently within the code block.

  • #1160 60d8c53 Thanks @cody-dot-js! - DataTable.Row now auto-applies cursor-pointer when an onClick handler is provided, so consumers no longer need to add it manually. Pass a different cursor-* class via className (for example, cursor-wait) to override.

  • #1160 60d8c53 Thanks @cody-dot-js! - Fix useComposedRefs returning a thunk (() => (node) => void) instead of a ref callback, which meant the composed ref never actually received the DOM node. The hook now returns a stable ref callback that reads the latest refs via an internal ref box, so passed refs stay up-to-date without causing ref thrashing on every render.

  • #1160 60d8c53 Thanks @cody-dot-js! - Breaking: useCopyToClipboard now returns the async copy function directly instead of a [state, copyFn] tuple. The internal useState that tracked the last copied value has been removed, eliminating an extra render per successful copy.

    // Beforeconst [, copyToClipboard] = useCopyToClipboard(); // Afterconst copyToClipboard = useCopyToClipboard();

0.70.1

Patch Changes

  • #1148 6085190 Thanks @cody-dot-js! - Add missing data-slot attributes across the component library so every exported component and compound sub-part exposes a stable data-slot="<name>" (or data-slot="<name>-<part>") styling hook. Also adds asChild support (via Slot) to Empty.Root, Empty.Actions, Code, and ButtonGroup.

    Components updated include (non-exhaustive): Main, SkipToMainLink, Empty, CodeBlock, Tabs, TextArea, Toast/Toaster, Separator, HorizontalSeparatorGroup, Sheet, Skeleton, Switch, Table, Label, MediaObject, Popover, RadioGroup, Select, Flag, HoverCard, Icon, SvgOnly, Input/PasswordInput, Kbd, DescriptionList, Dialog, DropdownMenu, DataTable, MetaKey, CursorPagination, ProgressBar, and ProgressDonut. HoverCard.Trigger, Dialog.Trigger/Dialog.Close, and DropdownMenu.Trigger are now thin wrappers around their underlying Radix primitives so they can carry a data-slot attribute.

    Note: ProgressBar's existing data-slot values were renamed to follow the compound-component naming convention — data-slot="progress" → data-slot="progress-bar" and data-slot="progress-indicator" → data-slot="progress-bar-indicator". Consumers styling against those specific values will need to update their selectors.

  • #1150 e623364 Thanks @cody-dot-js! - Anchors (<a> elements, including those rendered by the Anchor component) that are descendants of Alert.Root now inherit the alert's variant text color (e.g. text-warning-700 inside a warning alert) and are underlined at all times — not just on hover. This keeps inline links visually coherent with the surrounding alert copy while still marking them as links.

    Visual change: consumers that currently render links inside Alert.Root will see those links shift from the default text-accent-600 to the alert's priority color, and the underline will be persistent rather than hover-only.

  • #1150 e623364 Thanks @cody-dot-js! - Add "when to use" guidance and WAI-ARIA cross-references to the Tooltip, Popover, and HoverCard compound-namespace JSDoc so editor hover tips surface the correct component for each use case.

0.70.0

Minor Changes

  • #1147 5e3aca7 Thanks @cody-dot-js! - Add SkipToMainLink and Main components for accessible "skip to main content" navigation. SkipToMainLink is a visually-hidden-until-focused link that uses the browser History API directly, so it works in any framework (React Router, Next.js, plain HTML). Main renders a focusable <main id="main" tabIndex={-1}> landmark designed to pair with it.

    Also registers a new z-max utility (backed by --z-index-max: 2147483647, i.e. int32 max) so consumers can stack elements above any other z-index layer.

Patch Changes

  • #1107 4d35209 Thanks @forzalupo! - Internal: switch === undefined / !== undefined checks to == null / != null for consistency with the project's nullish-equality style. No behavior change.

0.69.2

Patch Changes

0.69.1

Patch Changes

  • #1141 f82feb8 Thanks @cody-dot-js! - Add a Composition section to every compound component doc page showing the structural tree of its parts (ASCII art). Rename existing asChild-style Composition sections on Badge, Button, IconButton, Separator, and SplitButton to Polymorphism. Rename the Input children-composition section to Child Elements and the Dialog tooltip section to Combining with a Tooltip to avoid colliding with the new Composition name.

    Add the same ASCII composition tree as a @example Composition block to the top-level namespace JSDoc of every compound component (Accordion, Alert, AlertDialog, Card, CodeBlock, Combobox, Command, CursorPagination, DataTable, DescriptionList, Dialog, DropdownMenu, Empty, HoverCard, MediaObject, MultiSelect, Popover, ProgressBar, ProgressDonut, RadioGroup, Select, Sheet, SplitButton, Table, Tabs, Toast, Tooltip) so consumers and LLMs see the full structural shape at a glance in IDE IntelliSense.

  • #1141 f82feb8 Thanks @cody-dot-js! - Expand JSDoc coverage across CodeBlock, Command, Dialog, Empty, Popover, Select, and Sheet compound components and their sub-parts for improved IntelliSense and documentation.

  • #1141 f82feb8 Thanks @cody-dot-js! - Fix several code fence metastring parsing bugs in mantleCodeRehypePlugin:

    • mantleShowLineNumbers, mantleCollapsible, and mantleDisableCopy are now stringified on HAST <pre> properties. Previously, boolean false values were dropped during HAST→JSX compilation, causing showLineNumbers=false fence meta to be silently ignored (the rendered <pre> ended up with data-mantle-line-numbers="true" and no left padding).
    • collapsible=true, collapsible=false, and collapsible="true" in fence meta are no longer silently dropped. The collapsible key now accepts the same bare-flag, key-value, and quoted-value forms as disableCopy.
    • When a key appears multiple times in fence meta (e.g. title="first" title="second"), getMetaValue now returns the last value, matching parseMetastring semantics. Previously it returned the first.
    • tokenizeMetastring now splits on tabs, newlines, and carriage returns in addition to spaces. Previously only literal spaces were treated as token separators, so meta like title="Foo"\tcollapsible was lexed as a single token.

0.69.0

Minor Changes

  • #1138 3f06774 Thanks @cody-dot-js! - Add new Empty compound component for rendering empty states. Includes Empty.Root, Empty.Icon, Empty.Title, Empty.Description, and Empty.Actions sub-components.

0.68.7

Patch Changes

  • #1134 5bde2bb Thanks @cody-dot-js! - Fix InputContainer focus ring flicker by preventing mousedown default on non-input children. Use flushSync in PasswordInput to animate the eye icon inline in the click handler after React commits the new icon to the DOM. Remove pointer-events-none from validation feedback icons.

0.68.6

Patch Changes

  • #1132 c0ea674 Thanks @cody-dot-js! - Fix Input focus ring showing as black when clicking the PasswordInput visibility toggle by using focus-within for both ring size and color. Add a blink animation to the PasswordInput eye icon toggle that respects prefers-reduced-motion.

0.68.5

Patch Changes

  • #1130 8c7866b Thanks @cody-dot-js! - fix(table): switch to border-separate for continuous sticky column indicator

    Replaced border-collapse with border-separate border-spacing-0 on Table.Element and moved row dividers from group-level borders (divide-y on <thead>/<tbody>/<tfoot>) to cell-level borders. This prevents table cells from clipping overflow content, allowing the StickyColIndicator shadow strip to extend across row boundaries and render as one continuous vertical line instead of per-row segments with visible gaps.

    Also changed overscroll-none to overscroll-x-none on the table scroll container so vertical page scrolling is no longer blocked when hovering over a table.

0.68.4

Patch Changes

  • #1122 327d46c Thanks @cody-dot-js! - Fix table horizontal scroll masking, improve scroll perf, and add DataTable.ActionHeader.

    • Table.Root: Split into outer wrapper (border, rounded corners, background) and inner scroll container (scroll-fade-x mask, overflow-x: auto, overflow-y: clip, overscroll-behavior: none). Tables only scroll horizontally and no longer bounce.
    • Scroll fade: Left and right edge fades driven by scroll position. Right-side fade correctly suppressed when a sticky right column is present (DataTable.ActionCell / DataTable.ActionHeader).
    • DataTable.ActionCell: Replaced per-cell box-shadow with bg-inherit and a StickyColIndicator child span (1px divider + soft leftward gradient). Tracks the row's background including hover state.
    • DataTable.ActionHeader (new): Sticky <th> that pairs with DataTable.ActionCell so the pinned action column aligns across header and body rows during horizontal scroll.
    • useHorizontalOverflowObserver: Now tracks scrolledToStart, coalesces rapid-fire scroll/resize/mutation events via requestAnimationFrame, and uses useLayoutEffect so corrections apply before the browser paints.

    See migrations/data-table-action-header-migration.md for migration guidance.

0.68.3

Patch Changes

  • #1119 0c20cf7 Thanks @cody-dot-js! - Default showLineNumbers to true in mantleCode() and highlightWithMantleShiki() so code blocks show line numbers by default. Single-line shell snippets (bash, sh, shell) default to false since line numbers add noise to one-liners like install commands.

0.68.2

Patch Changes

0.68.1

Patch Changes

0.68.0

Minor Changes

  • #1067 ab6da43 Thanks @forzalupo! - Mantle theme updates: updates the functional colors, and modifies the color treatments across various components. It also adds font smoothing globally, while retaining the "auto" setting for Family, for a perceived higher weight.

Patch Changes

  • #1099 4a81875 Thanks @cody-dot-js! - Bump dependencies: @ariakit/react to 0.4.24, browserslist to 4.28.2, tailwindcss peer to ^4.2.2

  • #1082 383d538 Thanks @dependabot! - Update ariakit and headlessui

  • #1104 2be1db1 Thanks @forzalupo! - Polish component styles: soften card/popover/dialog hover backgrounds with color-mix(), update SplitButton menu item icon placement and gap, use bg-active-menu-item in Command palette, reduce table header height, improve Combobox item icon sizing, and flatten DataTable ActionCell layout.

  • #1106 41ce842 Thanks @forzalupo! - Themed code block with granular syntax highlighting: richer Shiki token color palette, semantic background tokens (bg-card/bg-base), redesigned copy button using IconButton, and subtler tab trigger styling. Server highlighter adds fine-grained token scope mappings for types, variables, operators, attributes, properties, and escape characters.

0.67.0

Minor Changes

  • #1018 f27c01f Thanks @cody-dot-js! - CodeBlock: replace PrismJS with Shiki for build-time syntax highlighting, removing Shiki/Prism from the browser bundle

Patch Changes

0.66.17

Patch Changes

  • #1071 c32a7d2 Thanks @dependabot! - Bundle prismjs into the code-block dist output to fix ReferenceError: Prism is not defined with Vite 8 / Rollup 4. Previously, prismjs component files (plain IIFEs with no module.exports) were left as external imports, and Rollup had no visibility into their implicit dependency on the prismjs main module — causing it to evaluate a component before window.Prism was set. Bundling prismjs directly resolves the ordering issue transparently for all consuming apps.

  • #1086 537de55 Thanks @cody-dot-js! - Add horizontal overflow scrolling to Tabs.List with scroll-position-aware edge shadows. When the tab list overflows its container it scrolls horizontally; fade shadows appear on whichever sides have hidden content and disappear when you reach an edge or when there is no overflow. Keyboard arrow-key navigation smoothly scrolls the focused trigger into view. Scroll bounce is disabled on the list.

0.66.16

Patch Changes

  • #1076 582bec9 Thanks @cody-dot-js! - Add --spacing-em (1em) theme token for font-relative sizing utilities like size-em, w-em, h-em

  • #1076 582bec9 Thanks @cody-dot-js! - Add default width="2.61em" height="1em" to NgrokWordmarkIcon and width="1em" height="1em" to NgrokLettermarkIcon so they render at font size without requiring explicit sizing classes

0.66.15

Patch Changes

0.66.14

Patch Changes

0.66.13

Patch Changes

0.66.12

Patch Changes

  • #1064 24ffd25 Thanks @cody-dot-js! - Decompose Command.Dialog into a Root/Trigger/Content namespace.

    Breaking change: Command.Dialog is now a compound namespace instead of a monolithic component. Migrate existing usage:

    - <Command.Dialog open={open} onOpenChange={setOpen}>
    -   <Command.Input placeholder="..." />
    -   <Command.List>...</Command.List>
    - </Command.Dialog>
    + <Command.Dialog.Root open={open} onOpenChange={setOpen}>
    +   <Command.Dialog.Content>
    +     <Command.Input placeholder="..." />
    +     <Command.List>...</Command.List>
    +   </Command.Dialog.Content>
    + </Command.Dialog.Root>
    

    The new Command.Dialog.Trigger sub-component allows opening the dialog from a button without managing open state manually:

    <Command.Dialog.Root>	<Command.Dialog.Trigger asChild>		<Button type="button">Open Command Palette</Button>	</Command.Dialog.Trigger>	<Command.Dialog.Content>		<Command.Input placeholder="Type a command or search..." />		<Command.List>...</Command.List>	</Command.Dialog.Content></Command.Dialog.Root>

    Also fixes two bugs:

    • Command.Dialog now correctly closes on Escape key press.
    • Command.Separator now automatically hides when all adjacent groups are filtered out.

0.66.11

Patch Changes

  • #1057 930e5fb Thanks @cody-dot-js! - Export fixMediaScriptContent from @ngrok/mantle/theme. This function returns the raw JavaScript string for the inline <script> that fixes theme stylesheet media attributes — useful for legacy Go services and other non-React servers that need to inline it directly into SSR HTML.

0.66.10

Patch Changes

  • #1055 714c79e Thanks @cody-dot-js! - fix(use-copy-to-clipboard): remove unnecessary async wrapper, guarantee polyfill DOM cleanup

0.66.9

Patch Changes

  • #1048 ca1ce98 Thanks @cody-dot-js! - fix(code-block): restore syntax highlight token styles to mantle.css

    The .token.* styles were moved out of mantle.css into a colocated syntax-highlight.css imported as a side-effect in code-block.tsx. However, tsdown extracts CSS imports into a sidecar file without emitting a corresponding import in the bundled JS, so consuming apps never loaded the styles. Moved the token styles back into mantle.css where they are reliably included.

0.66.8

Patch Changes

  • #1038 00fda43 Thanks @cody-dot-js! - Breaking: MantleStylesheets is renamed to MantleStyleSheets (and MantleStylesheetsProps → MantleStyleSheetsProps).

    The component no longer imports CSS ?url paths internally. Instead, pass the browser-accessible CSS URLs as required props using the new mantleStyleSheetUrls helper, which collects the three Vite ?url imports into a spreadable object:

    // Beforeimport { MantleStylesheets } from "@ngrok/mantle/theme";<MantleStylesheets nonce={nonce} ssrCookie={ssrCookie} />; // Afterimport darkCssUrl from "@ngrok/mantle/mantle-dark.css?url";import darkHighContrastCssUrl from "@ngrok/mantle/mantle-dark-high-contrast.css?url";import lightHighContrastCssUrl from "@ngrok/mantle/mantle-light-high-contrast.css?url";import { mantleStyleSheetUrls, MantleStyleSheets } from "@ngrok/mantle/theme"; const themeUrls = mantleStyleSheetUrls({	darkCssUrl,	lightHighContrastCssUrl,	darkHighContrastCssUrl,}); <MantleStyleSheets {...themeUrls} nonce={nonce} ssrCookie={ssrCookie} />;

    This fixes a build error (Unknown file extension ".css") in apps using React Router's SSR build, where ?url imports inside node_modules are not transformed by Vite.

    When forceTheme is set to a non-light theme, only the link tag for that theme is rendered — the others are omitted to avoid unnecessary network requests. forceTheme="light" renders no link tags since light is the base theme with no dedicated lazy stylesheet.

0.66.7

Patch Changes

  • #1036 1521814 Thanks @cody-dot-js! - Move syntax highlight token styles into the code-block component

    The .token.* CSS rules for syntax highlighting were previously included unconditionally in mantle.css, adding to the critical CSS payload for all pages even those with no code blocks.

    These styles are now colocated in packages/mantle/src/components/code-block/syntax-highlight.css and imported as a side-effect from code-block.tsx. Vite bundles them as a CSS chunk associated with the code-block module — apps that never import @ngrok/mantle/code-block no longer pay the cost, and apps that do get the styles automatically.

  • #1036 1521814 Thanks @cody-dot-js! - Add MantleStylesheets component and split dark/high-contrast themes into separate CSS files

    New: MantleStylesheets

    A new component exported from @ngrok/mantle/theme that renders <link rel="stylesheet"> tags for the dark, light-high-contrast, and dark-high-contrast theme CSS files. Each stylesheet is gated behind a media attribute matching its OS preference, making it non-render-blocking for users whose OS does not match.

    Place it in <head>, immediately after <PreventWrongThemeFlashScript>:

    <head>	<PreventWrongThemeFlashScript nonce={nonce} />	<MantleStylesheets nonce={nonce} ssrCookie={loaderData?.ssrCookie} /></head>

    Props:

    • forceTheme?: ResolvedTheme — force a specific theme's stylesheet to media="all" unconditionally (e.g. for a dark-only app)
    • ssrCookie?: string — pass the extracted theme cookie (via extractThemeCookie) so the server renders the correct media attribute directly in SSR HTML, eliminating FOUC for users with a manually-selected non-system theme
    • nonce?: string — CSP nonce for the inline fix script

    An inline <script> is rendered after the <link> tags. It runs synchronously before first paint and corrects any media attributes based on html[data-applied-theme] set by PreventWrongThemeFlashScript, covering cases where ssrCookie is not provided. On the client, a MutationObserver watches html[data-applied-theme] to keep media attributes in sync as the user changes their theme.

    New CSS exports

    Three new CSS files are now exported from @ngrok/mantle:

    • @ngrok/mantle/mantle-dark.css — dark theme custom properties
    • @ngrok/mantle/mantle-light-high-contrast.css — light high-contrast theme custom properties
    • @ngrok/mantle/mantle-dark-high-contrast.css — dark high-contrast theme custom properties

    These files contain only CSS custom property blocks and do not need to be added to your app's @import chain — MantleStylesheets handles loading them via <link> tags. mantle.css continues to hold the light theme and all Tailwind directives and must remain in your CSS @import chain as before.

  • #1036 1521814 Thanks @cody-dot-js! - Add @ngrok/mantle-vite-plugins package and source-all.css, optimize mantle.css

    New: @ngrok/mantle-vite-plugins

    A new package that exports mantleSourcePlugin — a Vite plugin that writes targeted Tailwind @source directives into your global CSS file for only the @ngrok/mantle components your app actually imports. Scans .ts, .tsx, .js, .jsx, .mdx, and .md files. Uses a disk-write approach (required because @tailwindcss/vite reads CSS from disk at startup before Vite's transform pipeline runs).

    New: @ngrok/mantle/source-all.css

    A zero-configuration alternative to mantleSourcePlugin. Import it alongside mantle.css to tell Tailwind to scan all mantle component dist files:

    @import "@ngrok/mantle/mantle.css";@import "@ngrok/mantle/source-all.css";

    Use this for apps that import most or all mantle components. Use mantleSourcePlugin for apps that import a subset and want the smallest possible CSS output.

    mantle.css optimizations

    • Removed @source "../dist" — source scanning is now opt-in via source-all.css or mantleSourcePlugin
    • Deduplicated --color-gray-* in light/dark themes using var(--color-neutral-*) aliases
    • Removed 21 light-theme color overrides (neutral-50–neutral-900, all red-* shades) that were identical to Tailwind v4 defaults
    • Moved ~60 semantic tokens (--background-color-base, etc.) from @theme {} to @theme inline {} to stop generating unused utility classes like bg-background-color-base

0.66.6

Patch Changes

  • #1034 949ec0f Thanks @cody-dot-js! - Update font-display policy for core fonts: roobert and jetbrains-mono (normal) use swap, italic faces (jetbrains-mono-italic, family-italic) use optional, family-regular stays fallback.

0.66.5

Patch Changes

  • #1032 b9a04cc Thanks @cody-dot-js! - Add preloadFontLink(name: CoreFontName): string — returns an HTTP Link header value that preloads a single core font. Sending preload hints as response headers lets the browser start fetching fonts before it parses any HTML, improving LCP on mobile.

    Remove MantleThemeHeadContent and PreloadCoreFonts.

    Migration: use PreventWrongThemeFlashScript in <head> and preloadFontLink in your HTTP response headers. For apps without header control, use individual PreloadFont elements instead.

    // entry.server.tsx — send font preloads as HTTP Link headersimport { assetsCdnOrigin, preloadFontLink } from "@ngrok/mantle/theme"; responseHeaders.set(	"Link",	[		`<${assetsCdnOrigin}>; rel=preconnect; crossorigin`,		preloadFontLink("roobert"),		preloadFontLink("jetbrains-mono"),		preloadFontLink("family-regular"),	].join(", "),);
    // root.tsx — only the FOUC script in <head>import { PreventWrongThemeFlashScript } from "@ngrok/mantle/theme"; <head>	<PreventWrongThemeFlashScript nonce={nonce} /></head>;
    // Non-SSR fallback — no header controlimport { PreloadFont, PreventWrongThemeFlashScript } from "@ngrok/mantle/theme"; <head>	<PreventWrongThemeFlashScript nonce={nonce} />	<PreloadFont name="roobert" />	<PreloadFont name="jetbrains-mono" />	<PreloadFont name="family-regular" /></head>;

0.66.4

Patch Changes

0.66.3

Patch Changes

  • 0dac608 Thanks @cody-dot-js! - Improve code block utility performance by speeding up indentation normalization and fmtCode, adding LRU caching for repeated syntax highlighting, and replacing linear language/indentation lookups with set membership checks. Also normalize CRLF input correctly, rename the indentation normalizer to normalize-indentation, and guard line-number expansion against excessively large ranges.

  • #1028 11f4575 Thanks @cody-dot-js! - add individual PreloadFont component, preload a core font by name

0.66.2

Patch Changes

  • #1023 bc1bc6c Thanks @cody-dot-js! - multi-select.tsx

    • close the combobox popover on Escape at the multi-select trigger level

    dialog/primitive.tsx

    • centralize onInteractOutside / onPointerDownOutside prompt-protection
    • add preventCloseOnNestedPopupEscape(...)
    • detect focused nested popup owners via aria-expanded="true" + aria-controls
    • only prevent parent modal close while the controlled popup is still mounted/open
    • add JSDoc documenting the first-ESC / second-ESC flow

    dialog.tsx

    • remove duplicated onInteractOutside / onPointerDownOutside wrappers

    sheet.tsx

    • remove duplicated onInteractOutside / onPointerDownOutside wrappers

    alert-dialog.tsx

    • remove duplicated onInteractOutside / onPointerDownOutside wrappers

0.66.1

Patch Changes

  • #1004 b04e08f Thanks @dependabot! - Update dependencies: tailwind, ariakit, react-day-picker

  • #1021 aab7d30 Thanks @cody-dot-js! - Swap from tsup to tsdown (6.2x speedup)

  • #1022 df6d158 Thanks @cody-dot-js! - Fix MultiSelect.Content inside modal Sheet, Dialog, and AlertDialog containers so it works with the default modal behavior without requiring modal={false}.

0.66.0

Minor Changes

0.65.0

Minor Changes

  • #1000 56a5245 Thanks @cody-dot-js! - Add @ngrok/mantle/utils export and new useInView hook.

    Breaking changes:

    • @ngrok/mantle/compose-refs is removed. Import composeRefs from @ngrok/mantle/utils and useComposedRefs from @ngrok/mantle/hooks instead.
    • @ngrok/mantle/utils/sorting is removed. Import sorting utilities from @ngrok/mantle/utils instead.

    New exports at @ngrok/mantle/utils:

    • inView(element, onStart, options) — framework-agnostic IntersectionObserver helper that calls onStart when an element enters the viewport. If onStart returns a function, it is called when the element leaves. Returns a cleanup function that disconnects the observer.
    • composeRefs and useComposedRefs — moved from @ngrok/mantle/compose-refs.
    • All sorting utilities — moved from @ngrok/mantle/utils/sorting.

    New hook at @ngrok/mantle/hooks:

    • useInView(ref, options) — React hook that returns true when the referenced element is visible in the viewport. Supports root, margin, amount, once, and initial options.

Patch Changes

  • #1001 922a053 Thanks @cody-dot-js! - Address review feedback from PR #1000.

    • inView: call observer.unobserve(element) before observer.disconnect() in the cleanup function for cleaner teardown.
    • useInView: restore intentional omission of isInView from useEffect deps (matching the upstream motion implementation); replace the bare eslint-disable comment with an explanation of why including isInView would cause the observer to restart on every visibility change.
    • tsup.config.ts: exclude compose-refs and sorting from individual build entries since they are now consolidated into the ./utils export.
    • hooks.mdx: fix missing forwardRef import in the useComposedRefs code example.
    • hooks.mdx: remove inView from the hooks page; it now has a dedicated page under the new Utils section.
    • hooks.mdx: add a live interactive demo for useInView.
    • Changeset: correct version bump type to major (breaking changes) and clarify useComposedRefs migration path to @ngrok/mantle/hooks.
    • Add use-in-view.test.tsx test coverage for the useInView hook.
    • Add a Utils section to the docs site with dedicated pages for cx, color, inView, composeRefs, and sorting.
  • #1002 f6e8271 Thanks @cody-dot-js! - Add optional icon prop to Alert.DismissIconButton.

    The dismiss button now accepts an icon prop (ReactNode) to render a custom icon in place of the default X icon.

  • #999 4ebf12b Thanks @dependabot! - Bump tsconfig target/lib to ES2023

  • #994 c4d9812 Thanks @dependabot! - Bump up min version of tailwind

0.64.3

Patch Changes

0.64.2

Patch Changes

  • #985 ce626cc Thanks @cody-dot-js! - Slider: Add color prop to customize the range fill color (defaults to "bg-accent-500"), and showTicks prop to render tick marks at each step interval. Also adds --color-card-border and --color-card-border-muted semantic color tokens.

0.64.1

Patch Changes

  • #980 c2735ed Thanks @dependabot! - Fix accessibility and linting issues in Input and RadioGroup.InputSandbox: replace aria-disabled/aria-invalid CSS hooks on wrapper divs with data-disabled, add role="none" to presentational wrapper divs

  • #984 b3a32ec Thanks @cody-dot-js! - Change font-display from swap to fallback in all @font-face declarations to eliminate layout shift (font bounce) while still downloading and caching fonts for subsequent loads

  • #981 e43df1c Thanks @cody-dot-js! - Bump up Slider thumb shadow to shadow-md (from shadow-sm)

  • #980 c2735ed Thanks @dependabot! - Update tailwind-merge

0.64.0

Minor Changes

  • #974 623935c Thanks @cody-dot-js! - Remove AutoScrollToHash component and useAutoScrollToHash hook. This also removes react-router as a peer dependency of @ngrok/mantle. If you were using AutoScrollToHash, move the implementation into your app directly.

  • #973 a8ff1c9 Thanks @cody-dot-js! - Add NgrokIcon to Icons

  • #979 68cc794 Thanks @cody-dot-js! - Add new Slider component, supports single, range, and multiple use cases

Patch Changes

  • #971 5781aae Thanks @cody-dot-js! - Fix doc block links

  • #978 286fab3 Thanks @cody-dot-js! - Fix jerky accordion close animation. Moved spacing from padding-top on the animated container to margin-top on the first child so it collapses smoothly with the height animation.

  • #974 623935c Thanks @cody-dot-js! - Add explicit generic type parameters to isValidElement calls in Button and Slot for React 19 type compatibility. React 19 changed ReactElement.props from any to unknown, requiring explicit type annotations to access props safely. This change is fully backwards compatible with React 18.

  • #978 286fab3 Thanks @cody-dot-js! - Fix CodeBlock.CopyButton timeout lifecycle handling by clearing pending timers on unmount and before scheduling a new copy-state reset.

  • #975 d9a7206 Thanks @cody-dot-js! - Fix theme flash on hydration by reading the theme cookie during SSR. useInitialHtmlThemeProps now accepts an optional ssrCookie string so the server can render the correct theme class, eliminating the mismatch between server HTML and the inline script.

0.63.2

Patch Changes

  • #969 76e1afd Thanks @cody-dot-js! - Fix useIsBelowBreakpoint performance regression during window resize. The subscribe and getSnapshot functions passed to useSyncExternalStore were recreated on every render, causing listener teardown/re-attach churn on each frame. They are now cached per breakpoint for referential stability.

  • #969 76e1afd Thanks @cody-dot-js! - Restore all @custom-variant definitions in mantle.css that were accidentally removed. This includes theme variants (light, dark, high-contrast, dark-high-contrast), aria/data attribute variants, hover media query variants (hover-hover, hover-none), and the where variant.

0.63.1

Patch Changes

0.63.0

Minor Changes

Patch Changes

0.62.2

Patch Changes

0.62.1

Patch Changes

0.62.0

Minor Changes

  • #954 7235e48 Thanks @randseay! - Add DescriptionList component.

    This component is a semantic wrapper for <dl>, <dt>, and <dd> elements. It allows for a more accessible and semantic way to display label/value pairs.

Patch Changes

  • #946 e095088 Thanks @cody-dot-js! - Add CssProperties, $cssProperties type and helper for supporting typechecking of react CSSProperties AND custom css properties (--foo-bar)

0.61.3

Patch Changes

0.61.2

Patch Changes

0.60.2

Patch Changes

0.60.1

Patch Changes

0.60.0

Minor Changes

  • #896 b58edd7 Thanks @cody-dot-js! - Add Slot component with automatic className merging

    Introduces a new @ngrok/mantle/slot component that wraps Radix UI's Slot with automatic Tailwind CSS className merging using cx. All internal components now use the Mantle Slot instead of importing directly from Radix UI, providing consistent className merge behavior across the design system where child classes take priority over parent classes.

Patch Changes

0.59.1

Patch Changes

0.59.0

Minor Changes

  • #885 918bdd9 Thanks @cody-dot-js! - Promote Tooltip component out of preview

    The Tooltip component has been promoted from preview to stable. This includes:

    • Updated documentation route from /components/preview/tooltip to /components/tooltip
    • Updated all JSDoc URLs to reflect the new documentation path
    • Added comprehensive API reference for TooltipProvider, Tooltip.Root, Tooltip.Trigger, and Tooltip.Content
    • Improved tooltip arrow styling with proper positioning and background matching
    • Fixed Tailwind 4 dev mode class scanning by adding @source "../src" to mantle.css

0.58.4

Patch Changes

  • #878 9ecfcd4 Thanks @dependabot! - Update dependencies; bump min version of tailwind

  • #883 e3d39ef Thanks @cody-dot-js! - move mantle.css to src/ and improve HMR

    • Moved mantle.css from packages/mantle/assets/ to packages/mantle/src/ to enable proper hot module reload during development
    • Updated mantle.css package export to use source conditions (@ngrok/mantle/source → src/mantle.css, default → dist/mantle.css), matching the pattern used for component exports
    • Added build step to copy mantle.css to dist/ directory for production builds

0.58.3

Patch Changes

  • #879 8a66858 Thanks @acrobat130! - enable an appearance prop on the tabs root component that allows the user to specify pill-style tabs

0.58.2

Patch Changes

  • #873 f8170b9 Thanks @dependabot! - Update dependencies; add static white, static black, and ff00ff as colors

0.58.1

Patch Changes

0.58.0

Minor Changes

0.57.5

Patch Changes

  • #864 aa7f600 Thanks @cody-dot-js! - Add filter and shouldFilter props to Command.Dialog for custom filtering behavior

0.57.4

Patch Changes

  • #858 df04845 Thanks @cody-dot-js! - fix: restore text-size-inherit utility

    Re-added text-size-inherit font-size utility that was accidentally removed in v0.57.3. This utility allows components to inherit font-size from their parent element.

    Note: This utility is named text-size-inherit (not text-inherit) to avoid conflicts with Tailwind's standard text-inherit utility which controls color inheritance.

    Changes:

    • Added --text-size-inherit: inherit; to @theme inline block
    • Added text-size-inherit to font-size class group in cx helper's tailwind-merge config
    • Added tests for proper class merging with text-size-inherit

0.57.3

Patch Changes

  • #856 20c2da9 Thanks @cody-dot-js! - fix: enable text-mono font-size overrides with cx helper

    Extended the cx helper's tailwind-merge configuration to recognize text-mono as a font-size class. This allows users to properly override the monospace font size in Table.Cell and CodeBlock components using standard Tailwind font-size utilities like text-base, text-xl, etc.

    Before:

    <Table.Cell className="text-base">INV001</Table.Cell>// Both text-mono and text-base were present in className// text-mono took precedence → 13px font-size

    After:

    <Table.Cell className="text-base">INV001</Table.Cell>// tailwind-merge properly removes text-mono// text-base takes effect → 16px font-size

    Technical Details:

    • Added text-mono to the font-size class group in extendTailwindMerge config
    • Added tests verifying proper class merging behavior
    • Updated table docs example to demonstrate font-size override

0.57.2

Patch Changes

  • #854 8819cb0 Thanks @cody-dot-js! - fix: convert text-size-mono to text-mono for proper tailwind-merge support

    Changed the custom @utility text-size-mono to a proper Tailwind theme fontSize extension --font-size-mono, which generates the text-mono utility class. This allows text-mono to be properly overridden by other font-size utilities like text-base or text-xl when using the cx helper.

    Breaking Change: Users need to replace text-size-mono with text-mono in their code.

    Before:

    <Table.Cell className="text-size-mono">...</Table.Cell>

    After:

    <Table.Cell className="text-base">...</Table.Cell> // text-base now properly overrides the default text-mono

0.57.1

Patch Changes

  • #852 d5cbefb Thanks @cody-dot-js! - Command: export MetaKey component for platform-aware keyboard shortcuts

    Extract MetaKey as a reusable SSR-safe component that displays the appropriate modifier key (⌘ for macOS/iOS, Ctrl for others) in keyboard shortcut hints.

0.57.0

Minor Changes

0.56.0

Minor Changes

0.55.5

Patch Changes

0.55.4

Patch Changes

0.55.3

Patch Changes

0.55.2

Patch Changes

  • #828 5cdda38 Thanks @cody-dot-js! - AutoScrollToHash + useAutoScrollToHash: switch to context provider, allow for programmatic scrollToHash()

0.55.1

Patch Changes

  • #826 48ce32d Thanks @cody-dot-js! - Add react-router to peer dependencies of mantle, document AutoScrollToHash peerDep on react-router

0.55.0

Minor Changes

Patch Changes

0.54.3

Patch Changes

0.54.2

Patch Changes

0.54.1

Patch Changes

0.54.0

Minor Changes

0.53.0

Minor Changes

Patch Changes

0.52.9

Patch Changes

0.52.8

Patch Changes

0.52.7

Patch Changes

0.52.6

Patch Changes

0.52.5

Patch Changes

0.52.4

Patch Changes

0.52.3

Patch Changes

  • #763 fe24bbb Thanks @cody-dot-js! - Card: fix how we render borders between Card.Header, Card.Body, and Card.Footer

0.52.2

Patch Changes

  • #760 9b8ea0b Thanks @melanieseltzer! - ProgressDonut: add indeterminateRotationSpeed prop to control spin speed. Fix default animation-duration-[15s] so it no longer overrides consumer-provided animation-duration-* classes.

0.52.1

Patch Changes

0.52.0

Minor Changes

Patch Changes

  • #743 9e2621c Thanks @cody-dot-js! - Switch Alert, Dialog, and AlertDialog Description components to render a div instead of a p by default

  • #746 639eed2 Thanks @cody-dot-js! - Update hooks documentation for useMatchesMediaQuery and useCopyToClipboard

0.51.3

Patch Changes

0.51.2

Patch Changes

0.51.1

Patch Changes

0.51.0

Minor Changes

  • #730 e61565f Thanks @cody-dot-js! - DataTable: remove DataTable.Rows convenience component; instead prefer to map over rows directly

Patch Changes

0.50.4

Patch Changes

0.50.3

Patch Changes

0.50.2

Patch Changes

0.50.1

Patch Changes

0.50.0

Minor Changes (0.x breaking)

0.40.1

Patch Changes

0.40.0

Minor Changes (0.x breaking)

0.32.3

Patch Changes

0.32.2

Patch Changes

0.32.1

Patch Changes

0.32.0

Minor Changes

0.31.6

Patch Changes

0.31.5

Patch Changes

0.31.4

Patch Changes

0.31.3

Patch Changes

0.31.2

Patch Changes

0.31.1

Patch Changes

  • #635 baa2d7f Thanks @cody-dot-js! - RadioIndicator: add shrink-0 so the indicator does not shrink in flex contexts

0.31.0

Minor Changes

Patch Changes

0.30.0

Minor Changes

Patch Changes

0.29.0

Minor Changes

0.28.1

Patch Changes

0.28.0

Minor Changes

0.27.4

Patch Changes

0.27.3

Patch Changes

0.27.2

Patch Changes

0.27.1

Patch Changes

  • #538 1c2f1af Thanks @cody-dot-js! - DataTable: add most of the remaining core components

  • #536 585624a Thanks @cody-dot-js! - DataTable: export both DataTableHeaderSortButton and DataTableHeader, decouple behavior to make it fully customizable

0.27.0

Minor Changes

  • #533 8211d3b Thanks @cody-dot-js! - Tailwind Preset: replace 'text-mono' with 'text-size-mono' (so it doesn't clash with tw-merge text-* color classes)

Patch Changes

0.26.0

Minor Changes

  • #525 bf41174 Thanks @cody-dot-js! - Add custom mantle icons exports (Sort, TrafficPolicyFile) and sorting helper functions, types

  • #527 f702d8a Thanks @cody-dot-js! - DataTable: add DataTableHeader component, which supports column sorting by default (if enabled on the column)

  • #527 f702d8a Thanks @cody-dot-js! - Table: improve documentation and fix markup, exports to match html spec 1:1; improve styling and include horizontal overflow detection

Patch Changes

0.25.2

Patch Changes

0.25.1

Patch Changes

0.25.0

Minor Changes

  • #501 a669615 Thanks @cody-dot-js! - CodeBlock: add support for setting the indentation used (tabs vs spaces), fallback to detecting what the given language prefers or spaces if none

Patch Changes

0.24.1

Patch Changes

0.24.0

Minor Changes

  • #491 97538d9 Thanks @cody-dot-js! - Anchor: add support for icons and their placement, improve docs and intellisense

0.23.2

Patch Changes

0.23.1

Patch Changes

0.23.0

Minor Changes

Patch Changes

0.22.2

Patch Changes

0.22.1

Patch Changes

0.22.0

Minor Changes

Patch Changes

  • #460 e62f0ef Thanks @cody-dot-js! - Select: improve component docs, add inline intellisense examples w/ links, make content width default to "trigger"

0.21.5

Patch Changes

0.21.4

Patch Changes

  • #456 2ed9953 Thanks @cody-dot-js! - Alert: improve doc site entry and intellisense for each component; remove unused default priority and make priority required; add AlertIcon component

0.21.3

Patch Changes

0.21.2

Patch Changes

0.21.1

Patch Changes

0.21.0

Minor Changes

Patch Changes

0.20.2

Patch Changes

  • #444 63ce798 Thanks @cody-dot-js! - Flags: rename Flags type to CountryCode, export countryCodes list and isCountryCode type predicate fn

0.20.1

Patch Changes

0.20.0

Minor Changes

0.19.17

Patch Changes

0.19.16

Patch Changes

0.19.15

Patch Changes

  • #431 7358307 Thanks @cody-dot-js! - DropdownMenuRadioItem: only take up space for the checkmark when an item is checked

0.19.14

Patch Changes

0.19.13

Patch Changes

0.19.12

Patch Changes

0.19.11

Patch Changes

0.19.10

Patch Changes

0.19.9

Patch Changes

  • #413 d42bb6f Thanks @cody-dot-js! - SelectItem: improve styling to fix hover bg color on selected items in docs use case

0.19.8

Patch Changes

0.19.7

Patch Changes

0.19.6

Patch Changes

0.19.5

Patch Changes

0.19.4

Patch Changes

0.19.3

Patch Changes