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
943c2ffThanks @cody-dot-js! - Add aheadprop toTruncatedText, fit its tooltip to the value, and drop its help cursor.headis the most graphemes that show before the ellipsis. A value longer thanhead + tailgraphemes 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 nochmath 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
helpcursor 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
08e9ff1Thanks @cody-dot-js! - AddThemeSwitcherIconanduseThemeToggleto@ngrok/mantle/theme-switcher, and letThemeDropdownMenuItemtakechildren. 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.ThemeDropdownMenuItemtakeschildrenin place of its default row. Put aThemeSwitcherIconwhere the icon goes. With nochildren, the item renders the same row as before.ThemeSwitcherIconis theThemeSwitchericon 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.useThemeTogglereturnsdisabled,label, andonSelectfor aDropdownMenu.Itemthat you compose yourself. A press flips the theme and keeps the menu open.
See https://mantle.ngrok.com/components/forms/theme-switcher#laying-out-the-row.
0.93.1
Patch Changes
-
#1636
122e171Thanks @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
asChildlink 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
dce0a88Thanks @cody-dot-js! -ThemeProvideranduseInitialHtmlThemePropsno longer throw in a sandboxed iframe that blocks cookies. In an iframe withsandbox="allow-scripts"and noallow-same-origin, a read ofdocument.cookiethrows aSecurityError. In such a frame, mantle now reads the theme fromssrCookie, so the client agrees with the server HTML. With nossrCookie, it renders the default theme.
0.93.0
Minor Changes
-
#1622
c05af74Thanks @cody-dot-js! - AddQueryand@ngrok/mantle/query-filter, the filter language it speaks.Queryis a single-line filter field where free text andkey:valuequalifiers interleave, such asis: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.Query.Rootowns the caret, the open state, and the active suggestion, and lays out its children in a row. It is controlled only: passvalueandonValueChange. Passitemsfor live match counts, or leave it out when a server runs the query.Query.Inputis the field, with a leading magnifier and a clear button that shows while the query has text. It takes the<input>props, such asdisabled,required,readOnly, andvalidation, asInputdoes. Its children render inside the field as inline actions, before the clear button.clearLabelnames the clear button, andonClearruns after a clear. InsideField.Control, it takes itsid,name, and ARIA wiring from theField.Item. Keys, values, andORuse theCodeBlockcolors. Set--query-key-color,--query-value-color, or--query-operator-colorto change them.Query.Contentis the suggestion popover. You compose its parts, and you leave out a part to remove it. It accepts thePopover.Contentprops, such assideandid, except the ones it owns.Query.Listis the listbox, grouped under "Filter by", "Exclude", and "Values". Its child is a function that gets each suggestion and returns aQuery.Item.Query.Itemis one suggestion row. It shows the label, the description, and the match count, or the children you pass. CallpreventDefault()in itsonClickto skip the insert.Query.Emptyshows when no suggestion matches, and the popover stays open. By default, it says what the field accepts at the caret.Query.Footerstays under the rows while they scroll, and it joins the input's description while the popover is open. It takesasChild.Query.Legendshows the syntax the grammar honors.Query.KeyboardHintsshows the keys the field answers to, andenterHintnames what Enter does.- Data attributes:
data-slotisquery,query-input,query-input-actions,query-content,query-list,query-item,query-empty,query-footer,query-legend, andquery-keyboard-hints.data-highlightedmarks the active row, or the row under the pointer.
@ngrok/mantle/query-filterhas no React import, so a server or a script can parse and match with the same rules. It exports theFilterGrammarcontract,defineRemoteFilterGrammar, the total parserparseFilterQuery,toggleFacetto add or remove one qualifier, the matchersfilterItemsByQueryandcountItemsByQuery,getFilterSuggestions, andgetHighlightSegments. For a natural-language search,describeFilterGrammardescribes the language as JSON for a model, andcheckFilterQueryfinds 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:
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
061c62aThanks @joelhans! -dotenvjoins the supported code block languages.mantleCode("dotenv"), Markdown fences, and the server highlighter render.envfiles with Shiki's dotenv grammar, which ships in the existingshikidependency. Before this change, adotenvfence was not highlighted, and the rehype plugin left it as a plain<pre>withoutCodeBlockstyles. -
#1622
c05af74Thanks @cody-dot-js! -useDebouncedCallbackaccepts a callback with typed parameters, such as(query: string) => void. Before, TypeScript rejected every callback that did not takeunknownarguments, including the one in the hook's own example. -
#1627
4970366Thanks @cody-dot-js! - Fix the indentation of tab-indented code inCodeBlockon Firefox. A tab now shows as two spaces in every browser. The code block wrote-moz-tab-size: 2px, which Firefox reads overtab-size, so each tab was 2px wide.CodeBlock.Codenow ignores aMozTabSizein itsstyleprop for the same reason. -
#1631
b6cd8a6Thanks @cody-dot-js! - Bump@tanstack/react-virtualfrom 3.14.22 to 3.14.23. -
#1629
c9c50aaThanks @cody-dot-js! - ShowSandbarabove an openDialog,AlertDialog, orSheetand 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
977abd8Thanks @cody-dot-js! - Keep theSliderthumb white in dark mode for better contrast.
0.92.1
Patch Changes
- #1620
694c2ddThanks @cody-dot-js! -TruncatedTextclips its head and keeps its tail again. In 0.92.0, Tailwind compiled the head and tailmax-widthrules to invalid CSS, and the browser dropped them. A long value overflowed its box, so a sibling such as theIdTagcopy button painted over the text.
0.92.0
Minor Changes
-
#1619
914629cThanks @cody-dot-js! -mantle.cssholds 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.cssis gone. Delete its?urlimport and every use of the URL, its CSS@import, and themantle-dark-styles<link>in a non-React<head>.MantleStyleSheets,mantleStyleSheetUrls,MantleStyleSheetsProps, andMantleThemeCssUrlsare gone from@ngrok/mantle/theme. Delete<MantleStyleSheets />and itsmantleStyleSheetUrlscall.PreventWrongThemeFlashScriptstays in<head>.
The resolved colors do not change, with one exception. When the
<html>theme class anddata-applied-themedisagree, the light rule now wins. Before, the stylesheet order decided.ThemeProviderandPreventWrongThemeFlashScriptkeep the two in step, so this affects only a page whose writers get differentforceThemevalues.The codemod deletes the imports, the element, and the call. Migration guide: https://mantle.ngrok.com/migrations/0012-single-stylesheet-migration
Patch Changes
-
#1615
1d37d30Thanks @cody-dot-js! -Accordion.TriggerIconnow stays centered on the first line of the trigger label when the label wraps. Before, it was centered on the full trigger height. -
#1610
81ee570Thanks @cody-dot-js! -$cssPropertiesfrom@ngrok/mantle/typesnow 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'sCSSProperties, sostyle={$cssProperties({ "--gap": 8, ...style })}compiles as before. A misspelled key that does not start with--, such asdisplya, now fails to compile. Before, it compiled and had no effect. Fix the spelling at that call site. -
#1610
81ee570Thanks @cody-dot-js! -useUndoRedospells its constraintT extends {}, the type thatNonNullable<unknown>resolves to. Calls compile as before. -
#1616
a511d5eThanks @cody-dot-js! -isApplePlatformreturnsfalseduring a server render. Before this release, it read thenavigatorglobal, 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
preferredWidthofSheet.Contentis now spelledsm:max-w-120, which is the same width assm:max-w-[30rem]. -
#1612
895815bThanks @cody-dot-js! - Every component and hook indistnow ships React Compiler output, except the virtualization hook insideList. That hook opts out on purpose with"use no memo", because the compiler cannot model@tanstack/react-virtual. Before this release, the compiler skippeduseInertSiblingsand the click handler ofDataTable.ControlCelland 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:
useInertSiblingsandDataTable.ControlCellget the same memoization as the rest of mantle.- You do not need to change your code.
-
#1614
b868a2bThanks @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:Listgrid 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-labelledbytext for the data-table caption withuseSyncExternalStore. A chart mount witharia-labelledbynow takes 2 commits instead of 3. Whenaria-labelledbynames an element that contains the chart, the caption skips the text of the data table. Sandbar.Rootsets its presence during render whenopenchanges. Open and close still take 2 commits each.AppLayout.SidePanelresets 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
classNameandstyleto the engine. CodeBlock.Codeattaches 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
Profilerprobe in the unit test environment, the same on 3 runs, against the previous source.
-
#1617
21f2f37Thanks @cody-dot-js! - Fix three behaviors that surprised consumers, and document the others in JSDoc and on the docs site.Separator, and soTabs.Separator, no longer shrinks to zero height in a flex container that overflows, such as a scrollingDialog.Body. AclassNamewith ashrinkclass still wins.- The
Sidebar.Root⌘B/Ctrl+Bshortcut now does nothing when an earlierkeydownlistener calledpreventDefault(), as theCommand.DialogRoot⌘Kshortcut already does. - The
--shadow-*tokens are now CSS custom properties on:root, so a stylesheet that Tailwind does not process can readvar(--shadow-md). See https://mantle.ngrok.com/base/shadows.
New JSDoc notes:
Button: pass an icon through theiconprop, never as a child. Theicongetssize-5unless the icon sets its own size,asChildwraps a plain<a>and notAnchor, and thedisabled={!isReady || isPending}pattern.Combobox.Input: Enter never submits a parent form while the combobox is open, with a free-text example.Combobox.Root: controlopenwhen you renderCombobox.Contentconditionally.Combobox.Content: children must be items, groups, or separators.- Charts: a
xKey,dataKey, orzKeythat no row has throws. The Root isaspect-videoby default, the legend scrolls pastmax-h-16, and amode="decorative"chart hides its children from assistive technology. ThemeProvider: themantle-ui-themecookie and the events that apply it again. Set the cookie and reload in browser automation.Dialog.Content,Sheet.Content, andAlertDialog.Content: theoverlay-promptclass 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 combineonce: truewithinitial: true.Breakpoint:xs(480px) is wider than most phones, so give eachxs:class an unprefixed base.- The docs site lists the reserved
⌘Kand⌘Bchords at https://mantle.ngrok.com/accessibility#reserved-keyboard-shortcuts.
0.91.1
Patch Changes
-
#1609
b26cff1Thanks @cody-dot-js! -AppLayout.Workspacefills the frame at every width again. It no longer caps at--app-layout-max-widthor centers withmx-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-widthvariable is gone and has no effect.See https://mantle.ngrok.com/layouts/app-layout#applayoutworkspace.
-
#1608
258e10fThanks @cody-dot-js! - Align component motion with the mantle motion rules.Popover,Tooltip,HoverCard,DropdownMenu,Select,Dialog,AlertDialog, andSheetnow enter in 200ms and exit in 150ms on theease-out-strongcurve. Underprefers-reduced-motion, they open and close without animation.Dialog,AlertDialog, andSheetplay their exit animation again. Since 0.83.8, they unmounted at once on close.- Floating content (
Popover,Tooltip,HoverCard,DropdownMenu, andSelect) now slides 4px instead of 8px. It slides back on exit, so the exit mirrors the enter. Sandbarnow enters in 200ms instead of 400ms and exits in 150ms onease-out-strong.- The
Sandbarblocked-navigation shake and theMultiSelectlocked-tag shake now play the form-control error shake: 4px, settling in 300ms. A shake that repeats while one runs restarts it. - The
Switchthumb, theProgressBarindicator, and theAlert,CodeBlock, andSplitButtoncarets now move in 200ms onease-out-strong, and stay still underprefers-reduced-motion. - The
PasswordInputvisibility icon now opens from half height onease-out-strong.
-
#1606
d9444efThanks @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 onThemeSwitcherno 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
8ef6e80Thanks @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.ButtonandIconButton: theisLoadingspinner swaps with theicon.CodeBlock.CopyButtonand theChartcopy 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
ButtonandIconButtonicon is now inside a<span data-slot="icon-swap">, in a<span data-slot="icon-swap-item">. The item that shows hasdata-state="active". The hidden items havedata-state="inactive"andaria-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
a913c10Thanks @cody-dot-js! - Remove the high-contrast themes, simplify the theme script, and makeThemeSwitchera light/dark toggle. See the migration guide; its codemod rewrites most call sites.- Two themes.
Themeis"system" | "light" | "dark", andResolvedThemeis"light" | "dark". The@ngrok/mantle/mantle-light-high-contrast.cssand@ngrok/mantle/mantle-dark-high-contrast.cssexports and thehigh-contrast:anddark-high-contrast:variants are gone."system"followsprefers-color-schemeonly;prefers-contrastno longer changes the theme. - A stored high-contrast preference degrades. A
light-high-contrastcookie reads aslightand adark-high-contrastcookie reads asdark.ThemeProviderrewrites the cookie on mount. - Smaller theme script.
PreventWrongThemeFlashScriptreads the cookie and sets the theme class. It no longer writes a cookie or reads the legacylocalStoragekey. MantleStyleSheetsrenders one<link>. It takesdarkCssUrlonly, andmantleStyleSheetUrlstakes{ darkCssUrl }. ThelightHighContrastCssUrl,darkHighContrastCssUrl,forceTheme,ssrCookie, andnonceprops and thefixMediaScriptContentexport are gone.ThemeSwitcheris a toggle.<ThemeSwitcher />replacesThemeSwitcher.Root,ThemeSwitcher.Trigger, andThemeSwitcher.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.ThemeSwitcherPropsreplaces the three part prop types.ThemeDropdownMenuItemreplacesThemeDropdownMenuRadioGroup. It is the toggle in menu form: one "Theme" row that ends in theThemeSwitchericon. Put it at the top level of an account menu, with no submenu. A press flips light and dark the same way aThemeSwitcherclick does, and the "System Preference" option is gone. The menu stays open, and the new theme grows in a circle from the icon. UnderforceTheme, the item is disabled.ThemeDropdownMenuItemPropsreplacesThemeDropdownMenuRadioGroupProps, and alabelprop replacesaria-labelandaria-labelledby.- A forced page disables the toggle. Under a
ThemeProviderwithforceTheme,useAppliedThemereturns the forced theme, andThemeSwitchershows that theme and is disabled. A click there changed only a preference the page did not show.
- Two themes.
Patch Changes
-
#1602
b304302Thanks @cody-dot-js! - Fix chart y-axis labels that overlap at short plot heights, and fix other chart papercuts.-
YAxisfits 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 paints0and200, not five stacked labels. -
YAxistickCountis 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]paints0, 100, 200, not0, 50, 100, 150, 200. When you omittickCount, the axis still aims for about 5 ticks. -
tickFormatreceives a second argument. OnYAxisit is{ defaultLabel, step }. OnXAxisit is{ defaultLabel }.defaultLabelis 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. -
Legendno 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-heightRoot. Set amax-h-*class inclassNameto change the bound. When the legend scrolls, it is in the Tab order, so a keyboard user can scroll it. -
A
dataKey,xKey, orzKeytypo 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.txtsays that it is an index. It points agents at the bundled.d.tsfiles 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):
-
-
#1605
6ff2f24Thanks @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, andSelect.Triggershake whenvalidationbecomes"error".- An
InputwhoseInputCapturealone hasvalidation="error"now shows the red border and shakes. Before, the outer chrome stayed gray. MultiSelect.Inputnow getsaria-invalidfrom thevalidationandaria-invalidprops onMultiSelect.Trigger. Before, a trigger outside aField.Itemshowed the error but did not report it to assistive technology.CheckboxandSwitchdo not shake.- The new
animate-shakeutility applies the same motion to your own control.
-
#1597
f6fc775Thanks @cody-dot-js! - Bump@tanstack/react-virtualfrom 3.14.21 to 3.14.22. -
#1599
a913c10Thanks @cody-dot-js! -RadioGroup.Buttonkeeps 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
e0aec73Thanks @cody-dot-js! -ThemeProvidershares the theme cookie acrossngrok.aiand its subdomains, such asapp.ngrok.ai. It setsdomain=.ngrok.aion those hosts, as it setsdomain=.ngrok.comonngrok.comhosts. A theme choice onapp.ngrok.ainow also applies onngrok.ai. One cookie cannot spanngrok.comandngrok.ai, so each site keeps its own choice. -
#1601
1592f2eThanks @cody-dot-js! -ThemeSwitcheranimates 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. Underprefers-reduced-motion: reduce, or in a browser without view transitions, the swap happens at once.The reveal is part of
setThemefromuseTheme, so every theme change in an app runs it, such as a command palette action or a settings page.setThemetakes 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.setThemealso 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 newSetThemeOptionstype describes the options.
0.90.4
Patch Changes
-
#1595
02a61a1Thanks @cody-dot-js! - Add a column width setting toDataTable. Setmeta: { width: "fit" }on a column, and passcolumnto itsDataTable.Headerand to eachDataTable.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 needclassName="w-px"on the header andclassName="whitespace-nowrap"on each cell.DataTable.Cellis now aDataTablepart. It takes an optionalcolumnprop, and itsdata-slotis"data-table-cell table-cell".DataTable.HeaderandDataTable.Cellstampdata-column-width="fit"on a fit column.- mantle adds
widthto TanStack Table's globalColumnMetainterface. A table with acolumnMetaslot must add the newDataTableColumnMetatype 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
5ba2ca6Thanks @cody-dot-js! - KeepAccordion.Triggerthe same size when its section opens. The trigger no longer moves 8px of its bottom padding toAccordion.Body, so the header and the gap to the content stay still while onlyAccordion.Contentmoves.Accordion.Bodynow haspt-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 adata-state-open:pb-4override fromAccordion.Trigger; it is now the default. -
#1591
5ba2ca6Thanks @cody-dot-js! - AddDataTable.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 isExpand …/Collapse …. The button setsaria-controlsonly when the row also renders a detail panel throughrenderExpanded. Previously it pointedaria-controlsat a missing element.When
DataTable.RowExpandButtonexpands a row, its sub-rows andDataTable.ExpandedRowfade 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
c2c9c6eThanks @cody-dot-js! - Stop a closedAppLayout.SidePanelfrom sliding when the viewport crossesmobileBreakpointor 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 mergedduration-*still sets how long a close runs.
0.90.2
Patch Changes
-
#1581
d781768Thanks @cody-dot-js! - Add a2xssize toIconButton: a 20px box with a 16px icon. Use it for a copy button or another action inside aDescriptionListrow, so the row keeps the same height as a row without an action.2xsis forIconButtononly;ButtonandSplitButtonkeep the sharedxstoxlscale. -
#1580
49e26dfThanks @dependabot! - Bump@tanstack/react-virtualfrom 3.14.19 to 3.14.21. -
#1585
d5ce63aThanks @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 asundefined. -
#1576
a3546a4Thanks @cody-dot-js! -Command.SearchTriggernow 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,searchActionFromKeyDowncounted code points, so it read such a key as a named key and dropped the character.
0.90.1
Patch Changes
-
#1572
869cd53Thanks @cody-dot-js! -Strata.Sectionsizes its columnsminmax(0, 1fr) minmax(0, 2fr)instead of1fr 2fr, and belowmdit sizes its one stacked trackminmax(0, 1fr)instead of the implicitauto. A bare1frtrack and an implicit track both have anautominimum that resolves to the content's min-content width, so one nowrap value inStrata.Body(a URL, an ID, a token) widened the section past its container and the page scrolled sideways. Each call site patched both columns withmin-w-0.The tracks now shrink to the room the section has, at every width. A
truncatechild clips with an ellipsis, andwrap-anywhereorbreak-allwraps, with nomin-w-0onStrata.HeaderorStrata.Body. Remove amin-w-0you set on either part for this reason; it is now redundant. The split is still one to two, and--strata-columnsstill re-splits it. Keep theminmax(0, …)form in an override:--strata-columns: 1fr 1frbrings the overflow back.Docs: https://mantle.ngrok.com/components/structure/strata#column-split
-
#1572
869cd53Thanks @cody-dot-js! -Tabs.RootcallsonValueChangeonce 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 controlledTabs.Rootheard the same value twice in one dispatch. A tab strip that navigates inonValueChangethen 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
869cd53Thanks @cody-dot-js! -TruncatedTextstands 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>, anAccordion.Trigger, aRadioGroupitem, 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
tabIndexand renders no tooltip, so it stamps nodata-state. The segments still clip,data-truncatedstill lands, and the visually hidden copy still gives the control's accessible name the whole value. AtabIndexprop still wins. Wrap the control inTooltipwhen 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 thatasChildrenders as a control is not inside one, and a control that gains or loses itshrefor 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
2e5a336Thanks @cody-dot-js! - AddStrata, 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.The parts:
Rootis the stack. It rules a hairline between adjacentSectionchildren — never above the first or below the last — and it scopes--strata-columnsfor all of them.Sectionis one titled band, and it owns its own vertical rhythm (py-8 first:pt-0) rather than taking it fromRoot, so a lone section reads correctly. Below themdbreakpoint it stacksHeaderaboveBody; atmdand up it splits the two into a1fr 2frgrid.Headeris the introductory column —TitleandDescription, spaced bygap-2. It renders a semantic<header>, which is generic rather than a secondbannerlandmark because it sits insideSection's<section>.Titlerenders an<h2>, one level below the page's<h1>. ComposeasChildwith the right element when the sections sit deeper in the outline.Descriptionrenders a<p>in muted body text. Optional.Bodyis the content column, and itsgap-4spaces stacked controls.
Sectiontakes no accessible name, so a page of sections adds noregionlandmarks — theTitleheadings already carry the outline, and a landmark list only stays useful whilemainandnavare easy to find in it. Setaria-labelledbyto theTitle'sidonly when a section is a destination a reader jumps back to. To tell apart controls that read alike across two sections, reach forField.SetandField.Legendinstead — assistive technology announces a landmark on entry, never per control.Public CSS variable:
Every part stamps a
data-slot(strata,strata-section,strata-header,strata-title,strata-description,strata-body) and acceptsasChild.
Patch Changes
-
#1367
4a976beThanks @cody-dot-js! -countryCodesno longer lists"016"twice. The list had 739 entries for 738 distinct codes. -
#1563
79ca242Thanks @cody-dot-js! - Add three hooks to@ngrok/mantle/hooksfor a surface that turns modal or dismissible in place, with no portal and no remount.AppLayout.SidePanelcomposes 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 getstabindex="-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,Tabon the last tab stop wraps to the first andShift+Tabon the first wraps to the last, in document order, and a child that prevents the default onTabkeeps it. It traps the keyboard only, so a menu or popover that portals out of the element keeps working; pair it withuseInertSiblingsto block pointer focus. Flipactiveto engage and release the trap on one mounted element.useInertSiblings(ref, { active })setsinerton 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 dragstargettoward one edge and callsonDismisspast 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.propertypicks what the drag writes:translateslides the target, andflex-basisshrinks a flex item the way a lane collapses.propertyandactivetake 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
2cfa2d3Thanks @cody-dot-js! - Bump the runtime dependencies to their latest patch release:@ariakit/reactfrom 0.4.41 to 0.4.42@tanstack/react-virtualfrom 3.14.18 to 3.14.19
No API change.
-
#1562
4915582Thanks @cody-dot-js! - Ship the migration guides as ast-grep codemods. Every migration under/migrationsthat has a syntactic rewrite now ships its rules in the package atdist/migrations/<NNNN>-<slug>.yml, and each guide opens with aCodemodsection that names what the rules apply and what stays manual.Run one against your source from a
@ngrok/mantleinstall:From a mantle checkout,
pnpm -w run migrate 0008 <path>runs the same rules, and--dry-runprints the rewrites first. Run your formatter after either command: a codemod keeps the surrounding layout as it found it. -
#1562
4915582Thanks @cody-dot-js! -dist/agent.jsonnow carries the full agent manifest: every component with its import path, status, summary, JSDoc summary,@exampleblocks, search aliases, and sibling boundaries, plus the hooks and utilities with their examples.dist/llms.txtlists every module on one line with its import path and summary. Both match whathttps://mantle.ngrok.com/api/components.jsonserves, so an agent with no network reads the same records fromnode_modules.Card, Well, Separator, and Empty now state their sibling boundaries in
@useWhenand@siblingsJSDoc tags. The manifest exposes them asuseWhenandsiblings, and the docs pages carry a matching "When to use" section. -
#1563
79ca242Thanks @cody-dot-js! -AppLayout.SidePanelnow floats over the card whenever theAppLayout.Workspacerow is too narrow to hold the rail, the card, and the panel at full width, whatever the viewport is. Before, onlymobileBreakpointdecided, so an expanded rail at a tablet width squeezed the page narrower than the panel beside it.AppLayout.Workspaceis the container namedapp-layout-workspace, and the threshold follows the rail beside the card, read through:has()onSidebar.Nav'sdata-slotanddata-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 expandedSidebar.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
@themefor a wider rail or panel.The panel's widths now read the row, not the viewport. The default
--app-layout-side-panel-widthisclamp(21rem, 24cqw, 27rem), a quarter of the row, so a row that--app-layout-max-widthcaps on a wide screen holds the panel at its 21rem floor and the page keeps the rest. Before,24vwof a 1920px viewport put a 27rem panel beside a page narrower than the panel. The floating strip ismin(24rem, 100cqw - 5rem), so a row narrower than the strip never holds a panel wider than itself. -
#1563
79ca242Thanks @cody-dot-js! -AppLayout.SidePanelcan now be swiped away in every presentation, a floating one is an overlay the reader can dismiss, and a covering one is modal.AppLayout.SidePanelHeaderis a swipe handle, through the newuseSwipeToDismisshook. 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, somotion-reduce:turns it into a cut, and a drag the wrong way meets rubber-band resistance. The row istouch-pan-y, so a vertical swipe still scrolls, and a tap on a control in the row stays a tap.Escapecloses a floating panel while focus is inside it. An open combobox or popover inside the panel takes the firstEscape, 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 ignoresEscape.- 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, goinertwhile it is open, and a focus trap wrapsTabinside 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. Thecomplementarylandmark keeps its name.
-
#1560
bd24dffThanks @cody-dot-js! -Sidebar.TriggerandAppLayout.SidePanelTriggernow default tosize="sm", so they sit level with the othersize="sm"ghost icon buttons in anAppLayout.Headerrow. Both parts take asizeprop to change it, andIconButton'sdata-sizereflects the value. Passsize="md"to keep the old height.
0.89.0
Minor Changes
-
#1557
5221685Thanks @cody-dot-js! -ButtonandIconButtonnow stampdata-disabledanddata-loadingby presence alone:data-disabled=""while the button resolves disabled (disabled,aria-disabled="true", orisLoadingwith no explicitdisabled), anddata-loading=""whileisLoading. An enabled, idle button carries neither attribute, anddisabled={false}keeps a loading button enabled withdata-loadingalone. Before, every button carried both with a"true"or"false"value, and mantle'sdata-disabled:variant matches the attribute's presence, so a consumer'sdata-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 thedata-disabled:variant, and:not([data-disabled])for the enabled state.Sandbar.SaveButtonandSandbar.DiscardButtonreflect the same two attributes from theButtonthey render. -
#1547
550b9c9Thanks @cody-dot-js! - Breaking: a chart's screen-reader data table mounts once focus lands inside the chart, not at mount. Every interactiveLineChart.Root,BarChart.Root,AreaChart.Root, andScatterPlot.Rootnow mounts dormant. A dormant chart ingests its data, throws itsdataKeyandxKeyinvariants (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 asCopyButton, 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 stampsdata-state="dormant"and thendata-state="engaged"after the props spread, so adata-stateyou 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 sharedResizeObserver, and the chart module builds itsIntlformatters on first use instead of at import.beforeprintactivates and paints every chart synchronously, andafterprintreleases the ones out of view. Focus inside a chart also activates it, so a chart focused withpreventScrollis 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 withoutIntersectionObserver'sscrollMargin, 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-describedbytarget, and it replaces the unslotted instructions span. It renders on every interactive chart from the first render atdata-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 Xfor 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, becauseIntloutput 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/mantleships for its own tests, they are there afterrender(). - The tooltip surface exists after the first hover or keyboard step:
await user.tab(); await user.keyboard("{ArrowRight}")before you querydata-slot="<kind>-tooltip".
Migrate a test suite that queries the table at mount:
- Focus the chart before you query the table:
await user.tab(), orelement.focus()insideacton therole="application"overlay, thengetByRole("table").await findByRole("table")covers a focus that lands asynchronously. - A stubbed
IntersectionObserverunder happy-dom, or noIntersectionObserverat 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 thedata-slot="<kind>-data-table"element. Scrolling the chart into view is not enough.CopyButtonhands 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
- Under a real browser, the canvas and the hover layers arrive in the observer task after mount:
-
#1547
550b9c9Thanks @cody-dot-js! - Breaking: the chart Roots takemodein place of thedecorativeboolean.LineChart.Root,BarChart.Root,AreaChart.Root, andScatterPlot.Roottakemode?: "interactive" | "static" | "decorative"."interactive"is the default and is the chart as it always was."decorative"is the olddecorativebehavior 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. Replacedecorativewithmode="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 namedrole="img", with the samearia-labeloraria-labelledbyan interactive chart takes and anaria-describedbyto 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 nodata-state. At activation its plot holds the canvas alone; the crosshair, hover band, and marker layers never mount. A composedTooltippart has no effect. Pointer events pass through to the row or card behind it. The series keep their colors.animatedefaults tofalsein static mode, because a list of sparklines that each play a reveal as the reader scrolls is motion with no information in it; passanimateto 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. Amodepicked 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">) requiresmode.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
modeprop'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.ChartModeis 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 }), notgetByRole("application", …), anduser.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
- A static chart is
-
#1550
dbf2900Thanks @cody-dot-js! - AddTruncatedText, 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 lasttailgraphemes (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 aTooltipProviderancestor. The root carriesuser-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 stampdata-slot="truncated-text","truncated-text-head","truncated-text-tail", and"truncated-text-value". While the value overflows, the root stampsdata-truncated. The root carriestranslate="no"by default. PassasChildto 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
5b0389bThanks @cody-dot-js! -AppLayoutgains 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 afterAppLayout.Content, for an assistant, a details pane, or any surface that opens beside the page and closes again. It renders an<aside>, thecomplementarylandmark, with the same card treatment as the content card, and reads like aSheetwith 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 asAppLayout.Headerso the two rows center on one line;AppLayout.SidePanelBody(data-slot="app-layout-side-panel-body"), the scroll region, the same box asAppLayout.Bodywith no landmark; and an optionalAppLayout.SidePanelFooter(data-slot="app-layout-side-panel-footer"), a bar pinned to the bottom edge outside the scroll region.AppLayout.SidePanelRootowns whether the panel is open, the waySidebar.Rootowns the rail:open+onOpenChangecontrolled, ordefaultOpenuncontrolled, closed by default. It is renderless; wrapAppLayout.Workspacewith it.AppLayout.SidePanelTrigger(data-slot="app-layout-side-panel-trigger") is the ghostIconButtonthat toggles it, witharia-expanded,aria-controls,data-state, and a tooltip with its label, the same shape asSidebar.Trigger; it needs aTooltipProviderancestor.useSidePanel()returnsopen,setOpen,toggle, andpanelIdfor a custom control;setOpenkeeps 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-widthbeside the card (clamp(21rem, 24vw, 27rem)) and the card absorbs the difference. Closed, it collapses to nothing and goesinert, 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 animatesflex-basisalone over 200ms on the new--ease-out-strongtheme 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. BelowmobileBreakpoint("sm","md", or"lg","lg"by default) the panel floats over the card. Closed, it slides off the end edge and goesinvisiblethe instant the slide ends (visibilityrides the transition), so a row that--app-layout-max-widthcaps 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 stampsdata-state="open" | "closed"anddata-hydrated.extractSidePanelStateCookie,serializeSidePanelStateCookie, andSIDE_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 intodefaultOpen, and write it fromonOpenChange, so the server HTML carries the right state with no first-frame correction.AppLayout.Workspacenow 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, amax-w-7xlpage with its padding, and an open side panel at its 27rem cap are each at full size.AppLayout.Rootstill paintsbg-baseedge to edge, andAppLayout.Noticestill spans the full window. Set the variable tononeonAppLayout.Rootto let the row fill any width, and mergeclassName="ml-0"on the workspace to pin the row to the start edge. The workspace is alsorelativeandmin-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
550b9c9Thanks @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 everyvar()substituted, and the canvas parses theoklch()andcolor-mix()strings mantle's tokens resolve to, so onegetComputedStyleread of the Root resolves every series and chrome color inside that Root's own cascade. A--color-chart-Noverride scoped to one Root still wins for that chart alone, as before. A barecurrentColorresolves from the Root's computedcolor. A customReferenceLinecolor resolves through the same read. A hidden probe element now exists only as a fallback for a value the canvas cannot parse, such aslight-dark()or acurrentColornested insidecolor-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 computedcolor, 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 newclassNameorstyleon the Root invalidates them too, so a--color-chart-Norcoloroverride that changes after the first paint repaints; an equal inlinestyleobject on a re-render does not. -
#1559
dd7e5deThanks @cody-dot-js! -Dialog.Headernow stacksDialog.TitleandDialog.Descriptionin a column and keeps every other child,Dialog.CloseIconButtonincluded, 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 inDialog.Bodyrenders as before.The stack keys on the
data-slotof the header's direct children. A wrapper around the title and the description owns their layout, so themin-w-0 flex-1wrapper workaround still works. -
#1557
5221685Thanks @cody-dot-js! - Every part now joins an incomingdata-slotahead of its own instead of replacing it, the contract COMPONENT_SPEC.md §3.2 states and thatSidebar,Tabs,Select, andAppLayoutalready kept. Adata-slotreaches a part two ways: mantle'sSlotforwards anasChildancestor's chain, and a wrapper passes its own name as a prop. TheSlotpath 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 readsidebar-trigger-tooltipwith notooltip-content,Sandbar.SaveButtonreadsandbar-save-buttonwith nobutton, andDataTable.Rootreaddata-tablewith notable.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 acceptsdata-slotin its props type throughWithDataSlot, and each part's docs table shows the value it renders.A selector on
[data-slot~="button"]now reaches aSandbar.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
e42a878Thanks @dependabot! - Bump the runtime dependencies to their latest patch or minor release:@ariakit/reactfrom 0.4.40 to 0.4.41@radix-ui/react-dialogfrom 1.1.23 to 1.3.1@radix-ui/react-dropdown-menufrom 2.1.24 to 2.2.1@radix-ui/react-hover-cardfrom 1.1.23 to 1.2.1@radix-ui/react-popoverfrom 1.1.23 to 1.3.1@radix-ui/react-progressfrom 1.1.16 to 1.2.1@radix-ui/react-selectfrom 2.3.7 to 2.4.1@radix-ui/react-sliderfrom 1.4.7 to 1.6.1@radix-ui/react-slotfrom 1.3.3 to 1.5.0@radix-ui/react-switchfrom 1.3.7 to 1.4.1@radix-ui/react-tabsfrom 1.1.21 to 1.2.1@radix-ui/react-tooltipfrom 1.2.16 to 1.4.1@tanstack/react-tablefrom 9.2.4 to 9.3.1@tanstack/react-virtualfrom 3.14.13 to 3.14.18
No API change.
TruncatedTextkeeps its contract under the new Radix tooltip: the root still carries noaria-describedbyfor the tooltip, so a screen reader reads the value once. A consumer's ownaria-describedbystill lands on the root. -
#1555
5b0389bThanks @cody-dot-js! -Sidebarrows now move with the rail. ASidebar.ItemButton, aSidebar.SearchTrigger, aSidebar.SwitcherTrigger, aSidebar.Separator, andSidebar.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 fromease-linearto the same curve.Sidebar.GroupLabel's fade moves fromease-linearto 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 theease-out-strongutility. The transitions honormotion-reduce:and hold until one frame after hydration, the way the rail's width transition does, and a mergedtransition-noneorduration-*replaces them through tailwind-merge.A
Sidebar.ItemButtonor aSidebar.SwitcherTriggercomposed as aDropdownMenu.Triggeror aDialog.TriggerinsideSidebar.Tooltipnow keeps its open highlight. The tooltip trigger's owndata-statereplaces the menu trigger's on the row, so the highlight also keys onaria-expanded, which the menu trigger stamps and the tooltip trigger does not.useSidebar()'ssetOpenandsetOpenMobilenow keep one identity across renders, the same contractuseSidePanel()'ssetOpenhas, so either is safe in an effect dependency list beside an inlineonOpenChangeoronOpenMobileChange.
0.88.0
Minor Changes
-
#1527
6f6a27bThanks @cody-dot-js! - Breaking:Tabs.Listdraws no border. ThehideBorderprop and thedata-hide-borderattribute are gone, and so is the side border of a vertical classic list. The newTabs.Separatorpart draws the hairline instead. Compose it directly afterTabs.List: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.Listnow 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.Separatorcomposes the mantleSeparator. It paints theseparatorcolor token, follows the root'sorientation(under the list when horizontal, beside the list when vertical), is decorative by default (role="none"), and acceptssemanticandasChild. It stampsdata-slot="tabs-separator",data-orientation, anddata-separator. As the root's direct child it pulls itself up by the new--tabs-gapCSS variable and sits flush against the list while the content keeps the gap. Inside a wrapper of your own, the offset stays off.Tabs.Rootnow sets--tabs-gap(default1rem) and reads it for itsgap. Set the variable, not agap-*class, to change the space between the list and the content. Agap-*class changes the gap alone and leaves the separator's offset at1rem.Migrate:
- Add
<Tabs.Separator />after every classicTabs.Listthat relied on the default border. A pill list never drew one. - Delete
hideBorderfrom everyTabs.List. A list that hadhideBorderneeds no separator. - Replace a
[data-hide-border]selector with a[data-slot~="tabs-separator"]selector, or with its absence. - On a
Tabs.Rootthat holds aTabs.Separatoras a direct child, replace agap-*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
- Add
Patch Changes
-
#1538
1720c3cThanks @cody-dot-js! -DescriptionList.Rootsizes its value columnminmax(0, 1fr)instead of1fr. A bare1frtrack has anautominimum 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 withmin-w-0onDescriptionList.Value.The column now shrinks to the room left beside the label. A
truncatechild clips with an ellipsis, andwrap-anywhereorbreak-allon the value or on a child wraps it, with nomin-w-0onDescriptionList.Value. Remove amin-w-0you set onDescriptionList.Valuefor 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.Rootstill scrolls to reveal it. Give that contentoverflow-x-autoto scroll it inside the row instead.Docs: https://mantle.ngrok.com/components/data-display/description-list#long-values
-
#1540
9016164Thanks @cody-dot-js! - Bumpreact-day-pickerfrom 10.0.1 to 10.0.2.Calendarpicks up its fixes with no API change. -
#1531
ad96b54Thanks @cody-dot-js! -Sidebar.Navnow stampsdata-hydratedone frame after hydration instead of in the hydration commit. A persisted-collapsed state that a controlledopenapplies after hydration (theuseLocalStoragerecipe) used to commit in the same flush as the stamp, so the collapse animated shut on page load. It now snaps into place.Sidebar.NavandSidebar.GroupLabelkey their hydration gates offdata-hydratedin CSS instead of toggling classes in JS, with one negated variant per gate:max-{breakpoint}:not-data-hydrated:hiddenfor the pre-hydration visibility gate,not-data-hydrated:transition-nonefor the panel's width transition, andgroup-not-data-hydrated/sidebar-nav:transition-nonefor the label's fade. The gates no longer override consumer utilities: aclassNamedisplay utility holds abovemobileBreakpointbefore hydration as well as after, and a consumertransition-noneorduration-*now replaces the default transition through tailwind-merge.Sidebar.Navalso ownsdata-stateanddata-hydrated: a value passed through props no longer overrides either.useIsHydratedAfterPaintis a new hook in@ngrok/mantle/hooks. It returnsfalseon the server, in the hydration render, and in the mount commit, thentruefrom the next animation frame. Use it to gate a CSS transition that must not run on a post-hydration state correction, whereuseIsHydratedflips too early.Sidebar.Navstampsdata-hydratedfrom it. -
#1527
6f6a27bThanks @cody-dot-js! - EveryTabspart now joins an incomingdata-slotchain instead of replacing it, so a part rendered through anasChildancestor 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.Badgenow acceptsref.Every
Tabspart documents thedata-*attributes it stamps, including thedata-orientation,data-state, anddata-disabledvalues 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
9ed4e53Thanks @cody-dot-js! -AppLayout.Bodyrenders mantle'sMainlandmark by default:<main id="main" tabIndex={-1}>withdata-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, andPageDownscroll the page as soon as the jump lands.idandtabIndexare no longer props onAppLayout.Body, for the same reason they are not props onMain.This is a breaking change for a shell embedded in a page that already owns a
mainlandmark (a docs demo, a test, a widget inside another layout). There the body now renders a secondmainwith a duplicateid. 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 onemain, but the innerMainis redundant. - In an embedded shell, replace
<AppLayout.Body>with<AppLayout.Body asChild><div>…</div></AppLayout.Body>. The part adds its classes anddata-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'sdata-slotnow ends inmain.
AppLayout.Headergains three slots that carry the row's flex, so no call site setsflex-1,min-w-0,shrink-0, orml-autoitself:AppLayout.HeaderStart(data-slot="app-layout-header-start") isshrink-0: the home ofSidebar.Trigger, at the row's start.AppLayout.HeaderContent(data-slot="app-layout-header-content") ismin-w-0 flex-1: the home of theBreadcrumbtrail. 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") isml-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, acceptsasChild, and appends its owndata-slotafter any you forward. A header composed without the slots renders as before.Every page in an app that renders
AppLayoutrenders oneAppLayout.Header, withSidebar.TriggerinAppLayout.HeaderStartand the route's breadcrumb trail inAppLayout.HeaderContent. The App Layout docs state that requirement and show the canonical shell: the shell rendersAppLayout.Contentaround its<Outlet />, and each route rendersAppLayout.HeaderandAppLayout.Bodyitself, 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-routesMigration guide: https://mantle.ngrok.com/migrations/0007-app-layout-header-slots-migration
API reference: https://mantle.ngrok.com/layouts/app-layout#applayoutbody
- In a shell that owns the document, replace
Patch Changes
-
#1524
4854ba3Thanks @cody-dot-js! -Sheetparts now read a private Radix dialog scope, so each one binds to the nearestSheet.Rootand to nothing else. ACommand.SearchTrigger,Command.DialogTrigger,Dialog.Trigger, orDialog.Closerendered insideSheet.Content(aSidebar.Navbelow itsmobileBreakpoint, say) now reaches theDialog.RootorCommand.DialogRootabove 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.TriggerorDialog.Closeinside a sheet with noDialog.Rootabove it throws Radix's "must be used withinDialog". UseSheet.TriggerandSheet.Closeto control a sheet. - A
Sheet.Trigger,Sheet.Close, orSheet.CloseIconButtoninside aDialog.Contentwith noSheet.Rootabove it throws the same error. UseDialog.Closethere.
The Sheet docs state the binding rule: https://mantle.ngrok.com/components/overlays/sheet#sheetroot
The Command docs now put
Command.DialogRootaboveSidebar.Nav, where⌘Koutlives the mobile sheet and the search row inside the sheet still opens the palette: https://mantle.ngrok.com/components/navigation/command#in-a-sidebar - A
0.86.3
Patch Changes
-
#1521
a48d20bThanks @cody-dot-js! -ComboboxandMultiSelectimport ariakit from@ariakit/react/comboboxand@ariakit/react/storeinstead of the@ariakit/reactbarrel. The barrel re-exports every ariakit component, so oneComboboximport 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/reactand@ariakit/react-componentsboth setsideEffects: falseand 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 ESMimportcondition, five rounds, and the median wall time: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
e26dc3bThanks @randseay! -Table.Headerchanges frompx-4topx-3, so the column label aligns with thep-3body text inTable.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 consumerclassNamewins through tailwind-merge.
0.86.1
Patch Changes
-
#1512
0ca0fbdThanks @cody-dot-js! -Command.DialogRootandSidebar.Rootno longer throw on akeydownthat carries nokey.Both bind a
windowlistener for their chord. The listener readsevent.key.toLowerCase(). The DOM type promises a string, so the read looked safe. A browser extension or a password manager can dispatch a bareEvent("keydown")instead of a realKeyboardEvent. The listener then threwCannot 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
748773cThanks @cody-dot-js! -Select.Contentno longer acceptsasChild. 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.
asChildhandsSlotevery child, andSlottakes exactly one, so the prop threwPrimitive.div failed to slot onto its childrenat 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
asChildtoSelect.Contentcrashes today, so nothing working breaks. Style the part withclassNameinstead.Select.Trigger,Select.Item,Select.Group,Select.Label, andSelect.SeparatorkeepasChild.
Patch Changes
-
#1505
748773cThanks @cody-dot-js! -AlertCenter.Itemno longer crashes on a page a browser translation engine has translated. The item portals itschildreninto 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 theremoveChildraisedNotFoundError. React re-threw the rawDOMException, 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.childrennow 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'stextContentpath, 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.IconandAlert.Contentstay direct flex items of the banner chrome and keep itsgap. 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-labelis public API, and the docs page lists it besidealert-center-item-host. -
#1505
748773cThanks @cody-dot-js! -routeBreadcrumb.contentnow documents what a content crumb must render: a host element at its root, never bare text.Breadcrumb.Listrenders 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 aBreadcrumb.Itemwrapper would nest an<li>in an<li>.The JSDoc on the
contentfactory and on theCrumbunion both carry the contract, and the breadcrumbs recipe states it above every content-crumb example. The type still takesReactNode; narrowing it toReactElementis a breaking change and waits for the nextminor. -
#1508
fc71ccaThanks @cody-dot-js! - Bump runtime dependencies:@ariakit/reactto 0.4.40 and@tanstack/react-virtualto 3.14.13.The
@ariakit/reactbump reachesComboboxandMultiSelect, whose items build on ariakit'sCommand. Version 0.4.40 fixesCommandkeyboard 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-virtualbump reachesList.VirtualRootandSelectableList.VirtualViewport, which compose the virtualizer'smeasureElementonto each row as a ref callback. The virtualizer no longer callsflushSyncfrom that callback, so React's development build no longer logs theflushSyncwarning when a row measures. The bump also keeps a row's key in the measurement cache, so agetItemKeycallback 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
748773cThanks @cody-dot-js! -AreaChart,BarChart,LineChart, andScatterPlotno longer crash theirTooltipandLegendrender 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,Escapedismissed the readout, or keyboard stepping cleared the point cursor. Google Translate reparents each of those text nodes into a<font>first, and theremoveChildthrewNotFoundError, 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
childrenreadout now renders inside a<div data-slot="area-chart-tooltip-label">, and achildrenlegend inside<div data-slot="area-chart-legend-label">. The other three charts spell thembar-chart-,line-chart-, andscatter-plot-. React removes that element instead of the text nodes, and an element removal cannot throw. Each div isdisplay: contents, so it adds no box: your content stays a direct layout child of the tooltip or the legend, and aclassNameyou 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
748773cThanks @cody-dot-js! - The@exampleonuseCommandDialogno longer teaches a shape that crashes a translated page. It renderedSearch everything for “{query}”as three children of aCommand.Item, andCommand.DialogRootresets 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 threwNotFoundErrorand blanked the page. The example is one template literal now, which makes the interpolation a lone child React writes throughtextContent. -
#1505
748773cThanks @cody-dot-js! -DataTable.ActionCellandDataTable.ActionHeadernow renderchildreninside a label span:data-table-action-cell-labelanddata-table-action-header-label. Both cells hold a sticky-column indicator besidechildren, 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 raisedNotFoundErrorand blanked the page. A placeholder cell that gains an action button, and anActionHeaderwhosechildrenfall back to the built-insr-onlylabel, both took that path.Moving the indicator after
childrencured its own mount and nothing else, so the 2026-08-04 guarantee forActionHeaderwas 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
748773cThanks @cody-dot-js! -DropdownMenu.SubTrigger,DropdownMenu.CheckboxItem, andDropdownMenu.RadioItemnow renderchildreninside a label span:dropdown-menu-sub-trigger-label,dropdown-menu-checkbox-item-label, anddropdown-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 raisedNotFoundErrorand blanked the page. Each span stays mounted, so a lone string label takes React'stextContentpath, which wipes the translation wrapper instead of removing a node the item no longer owns. Each span isdisplay: contents, so an icon and its label stay flex items of the item and keep itsgap.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.Itemrenders 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
748773cThanks @cody-dot-js! -MultiSelect.Itemnow renderschildreninside 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 raisedNotFoundErrorand blanked the page. The wrapper stays mounted, so a lone string label takes React'stextContentpath, 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 aMediaObject, whose root is a<div>that a<span>may not contain. It isdisplay: contents, so a child of your own stays a flex item of the option and anyflex-1on it still resolves.multi-select-item-labelis public API, and the docs page lists it besidemulti-select-item. -
#1509
171c72dThanks @cody-dot-js! -asChildnow works onDropdownMenu.SubTrigger,DropdownMenu.CheckboxItem,DropdownMenu.RadioItem,Select.Trigger,Select.Item, andMultiSelect.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
refmerge onto it.The label wrapper each part carries for translated pages rides along, so a bare string inside your child stays safe on both paths.
-
#1505
748773cThanks @cody-dot-js! -Select.Triggernow renderschildreninside 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 raisedNotFoundErrorand blanked the page. A trigger that rendersselected ? <span>{selected.label}</span> : "Select"took that path on the first pick. The span stays mounted, so a lone string child takes React'stextContentpath, which wipes the translation wrapper instead of removing a node the trigger no longer owns.The span is
display: contents, soSelect.Valuestays a flex item of the trigger and keeps itsgap.Migration.
Select.Valueis 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-leftto[&>[data-slot=select-trigger-label]>span]:line-clamp-1and[&>[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-nonenow lands on the wrapper:line-clampsetsdisplay, which gives the wrapper a box and takesSelect.Valueout of the trigger's flex row.select-trigger-labelis public API, and the docs page lists it besideselect-trigger. -
#1505
748773cThanks @cody-dot-js! -SelectableList.Emptyno longer crashes on a page a browser translation engine has translated. The part is arole="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 asNo results for <strong>{query}</strong>., puts real text nodes there. Google Translate reparents each one first, and theremoveChildthrewNotFoundError, 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 isdisplay: 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'stextContentpath 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
748773cThanks @cody-dot-js! -SelectableList.SelectAllnow renderschildreninside 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 raisedNotFoundErrorand blanked the page.The span is
display: contents, so the checkbox and the label stay flex items of the header and keep itsgap.selectable-list-select-all-labelis public API, and the docs page lists it besideselectable-list-select-all. -
#1505
748773cThanks @cody-dot-js! -Sidebar.Trigger,Sidebar.Tooltip, andSidebar.SearchTriggerno longer crash on a page a browser translation engine has translated. Both defects were the same shape: ashortcutcrossingnullchanged the rendered node type, so React deleted the whole child list instead of appending. The translated label was reparented by then, and that removal raisedNotFoundError, 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 ashortcutthat resolves after the first paint appends beside an element.Sidebar.SearchTriggerbranches onasChildrather than onshortcut, and its<span data-slot="sidebar-search-trigger-shortcut">is always mounted on the defaultbuttonpath. That span isdisplay: nonewhile it is empty, so a row with no chord keeps its layout and its flexgapexactly as before. UnderasChildthe span is absent, which keepsSlotat 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
labelin a<span data-slot="sidebar-tooltip-label">, andSidebar.SearchTriggerwrapschildrenin a<span data-slot="sidebar-search-trigger-label">on the defaultbuttonpath. Both aredisplay: contents, so neither adds a box. The chord chips carrysidebar-tooltip-shortcutandsidebar-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.SearchTriggeris now a grandchild on the defaultbuttonpath, 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 theasChildpath 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 thebuttonpath. -
#1505
748773cThanks @cody-dot-js! -Tabs.Triggernow renderschildreninside a<span data-slot="tabs-trigger-label">, on the plain path and theasChildpath 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 raisedNotFoundErrorand blanked the page. The span stays mounted, so a lone string label takes React'stextContentpath, which wipes the translation wrapper instead of removing a node the trigger no longer owns. The span isdisplay: contents, so the icon, the label, andTabs.Badgestay flex items of the trigger and keep itsgap.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-5to[&>[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-labelis public API, and the docs page lists it besidetabs-trigger. -
#1505
748773cThanks @cody-dot-js! -Toast.Rootnow renderschildreninside 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 raisedNotFoundErrorand blanked the page. A toast thatmakeToastupdates in place under a repeated id took that path.The wrapper is a
<div>becauseToast.Messagerenders a<p>, which a<span>may not contain. It isdisplay: contents, so the icon, the message, and an action stay flex items of the root andToast.Message'sflex-1still resolves.toast-labelis public API, and the docs page lists it besidetoast. It wraps everything you pass the root, not only the message text. -
#1505
748773cThanks @cody-dot-js! -Tooltip.Contentnow renderschildreninside 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 raisedNotFoundErrorand blanked the page. The wrapper stays mounted, so a lone string body takes React'stextContentpath, 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 isdisplay: contents, so it adds no box and every child of your body stays a layout child of the surface.tooltip-labelis public API, and the docs page lists it besidetooltip-content.TooltipProviderno longer stampsdata-slot="tooltip-provider": the Radix provider renders no DOM, so that attribute named an element that never existed.
0.85.2
Patch Changes
-
#1501
b4f2d00Thanks @cody-dot-js! - EverySelectpart now joins adata-slotyou pass ahead of its own slot instead of replacing it, so<Select.Trigger data-slot="app-trigger">rendersdata-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, anddata-validationonSelect.Trigger;data-state,data-side, anddata-alignonSelect.Content; anddata-state,data-highlighted, anddata-disabledonSelect.Item. EverySelect.Rootprop carries a description.Select.Contentno longer animates when the user prefers reduced motion. -
#1501
b4f2d00Thanks @cody-dot-js! -Selectno longer crashes on a page a browser translation engine has translated. Radix portals the selected item's text intoSelect.Valueand 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 theremoveChildthrewNotFoundError, which tore down the React root and blanked the page.Select.Itemnow renderschildreninside a<span data-slot="select-item-label">.Select.Valuerendersplaceholderinside a<span data-slot="select-placeholder">and your ownchildreninside a<span data-slot="select-value-label">. React removes those elements instead of a text node, and an element removal cannot throw. Each span isdisplay: 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-1keeps working. A lone string child onSelect.Valuealso takes React'stextContentpath now, which wipes the translation wrapper, so a swap between text and an element there is safe too.Select.Valuealso carriesdata-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 aSelect.Itemnow 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
886cb87Thanks @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 thereact/compiler-runtimeimport the compiled code uses. The build runs the compiler throughoxc-transform-react, the Rust port of the React Compiler.DataTablecompiles too. TanStack Table v9 keeps itsrow,column, andheaderobjects stable, so the parts that read state through them (Header,Row,HeaderSortButton,RowExpandButton,ExpandedRow) now read it inside a TanStackSubscribeboundary, as TanStack's React Compiler guide describes. Each boundary also re-runs whenuseTablereceives new options.DataTable.Rowsubscribes to the whole table state, so a column'scellrenderer can keep readingrow.getIsSelected()and similar methods, and can keep closing over your component's values.List.VirtualRootreadsuseVirtualizerthrough 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:
0.85.0
Minor Changes
-
#1492
82d2c3bThanks @cody-dot-js! - MoveDataTableto TanStack Table v9 (@tanstack/react-table9.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-tablestill 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-migrationBreaking: the table instance you build changes. Every
DataTablepart keeps its name and props, but the TanStack calls around it move to the v9 API:useReactTableis nowuseTable, and its options requirefeaturesfromtableFeatures({ … }).- 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 nowcreateColumnHelper<typeof features, TData>(), and the column array must go throughcolumnHelper.columns([ … ]). A bare array no longer typechecks against thecolumnsoption.table.getState()is now thetable.stateproperty.sortingFnis nowsortFn, and a string name resolves only against thesortFnsslot. RegistersortFn_alphanumeric,sortFn_datetime, andsortFn_textthere to keep v8 auto-sort behavior; without a slot, auto-sort falls back tosortFn_basic. The exportedsortFnsandfilterFnsregistries are deprecated in v9, so import the functions you use.- A column's
filterFn: "auto"default and every string name resolve only against thefilterFnsslot. A grouped total withaggregationFn: "auto"or a string name resolves only againstaggregationFns, and it needsrowAggregationFeatureregistered. A function value bypasses both slots. RegisterincludesString: filterFn_includesStringto keep v8 column filtering on string columns, andsum: aggregationFn_sumto keep v8 totals on group rows. Without them, a column filter returns every row and a group row'sgetValue()isundefined; only a development build warns. - The instance types gain a leading
TFeaturesparameter:Column<typeof features, TData, TValue>,Row<typeof features, TData>,Table<typeof features, TData>,ColumnDef,HeaderContext,CellContext, andTableOptions. State types such asSortingStateandExpandedStateare unchanged.
What the mantle parts now require.
DataTable.HeaderSortButtonaccepts acolumnonly from a table that registersrowSortingFeature, andDataTable.RowExpandButtonaccepts arowonly from a table that registersrowExpandingFeature. Both are compile-time errors on that prop.DataTable.Row,DataTable.EmptyRow, andDataTable.ExpandedRowwork with or withoutcolumnVisibilityFeature: with it they respect hidden columns, without it they render every column.DataTable.Headersetsaria-sortonly when the table can sort.Not supported. mantle does not re-export the deprecated
@tanstack/react-table/legacyshim (useLegacyTable,legacyCreateColumnHelper, theget*RowModelstubs). Move each table touseTableinstead.Performance. Measured against
main(v8) with a 6-column sortable table built from theDataTableparts, in headless Chromium on production React 19.2.8, medians of 3 rounds:A consumer bundle that registers only
rowSortingFeatureis 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
4ae38d3Thanks @cody-dot-js! -Table.Rootno longer forces a layout inside React's commit. Its overflow observer used to readscrollWidthin a layout effect on mount, which laid out the whole table before React could finish the commit. The first read now runs in theResizeObservercallback, after the browser's own layout, and aflushSyncthere keepsdata-x-overflow,data-x-scroll-end, anddata-sticky-activeon the first painted frame. On a 1,000-rowDataTablethe 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
af79e2eThanks @cody-dot-js! -PasswordInputnow animates its eye icon on every visibility change, including a change toshowValuefrom 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
af79e2eThanks @cody-dot-js! -PasswordInputnow documents every data attribute it stamps, in its JSDoc and on its docs page:data-slot="input-capture"on the<input>, anddata-disabledanddata-validationon the chrome, beside the twodata-slotvalues it already listed.PasswordInputPropsgained a JSDoc summary. See the PasswordInput API.
0.84.2
Patch Changes
-
#1478
36aceb1Thanks @cody-dot-js! -Accordioncollapsed content now leaves the accessibility tree and the tab order in every browser, as the docs state. In browsers that supporthidden="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 collapsedAccordion.Contentnow carriesinert. 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.Triggernow carriesaria-controls, andAccordion.Contentcarries the matchingid, as the WAI-ARIA disclosure pattern requires.Accordion.Itemgenerates the id so the pair matches in the server HTML, andidis no longer a prop onAccordion.Content. -
#1478
36aceb1Thanks @cody-dot-js! -AlertDialog.Contentnow carriesrole="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 hadrole="dialog"and a click on the backdrop dismissed it. Escape,AlertDialog.Cancel, andAlertDialog.Closestill close it. YouronInteractOutsidehandler still runs; the content callsevent.preventDefault()after it.The
AlertDialog.IconJSDoc and docs now describe its real props (svgon an<svg>element, noasChild), and every namespace member's JSDoc summary matches its part. -
#1478
36aceb1Thanks @cody-dot-js! -Anchorrendersrel="noopener noreferrer"whentarget="_blank"andrelis omitted. The JSDoc and docs page promised this default; the code never set it. Arelyou pass is used as-is. Theiconprop is now decorative by default (aria-hiddenunless the SVG carries a name, a role, or an explicitaria-hidden), as the docs already said, and the docs API table gains therelrow. -
#1478
36aceb1Thanks @cody-dot-js! - Make a disabled or loadingButtonandIconButtoninert underasChild. The nativedisabledattribute does nothing on an<a>, so before this change a disabledasChildlink announcedaria-disabled="true"but stayed in the tab order and still navigated on click. The child now gets nodisabledattribute; it getsaria-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 ownonClickCapturewhile disabled, because Radix runs a child handler before a slot handler. The dimmed styling applies througharia-disabledas well asdisabled. Pointer events stay on, so aTooltipthat explains why the button is disabled still opens on hover.disabledandisLoadingnow 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, anddata-icon-button), nameisLoadingwhere they once named astateprop that never existed, and documentButtonGroup. -
#1478
36aceb1Thanks @cody-dot-js! -Calendarnow mergesclassNamewithclassNames.rootinstead of letting aclassNames.rootreplace it. Its JSDoc documents thedata-slot="calendar"hook and states that it is a month grid with no text entry. -
#1478
36aceb1Thanks @cody-dot-js! - Align theCardnamespace JSDoc with each part's declaration, soCard.Root,Card.Body,Card.Header, andCard.Titleshow one summary in every editor surface. -
#1478
36aceb1Thanks @cody-dot-js! - Close accessibility and documentation gaps shared byBarChart,LineChart,AreaChart, andScatterPlot.- 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
decorativechart is nowinert, so a composedCopyButtoninside the hidden backdrop is no longer a tab stop. CopyButtonannounces "Copied" through an always-mountedrole="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-labelledbyelement, so a chart named by a heading gets a distinct caption instead of "Chart data". BarChart.Grid,BarChart.XAxis,BarChart.YAxis, andBarChart.ReferenceLineJSDoc now state the horizontal-orientation behavior, and theGriddefault reads "perpendicular to the bars".
-
#1478
36aceb1Thanks @cody-dot-js! -Checkboxsetsaria-readonly="true"whenreadOnlyis set. The nativereadonlyattribute 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
36aceb1Thanks @cody-dot-js! -Choice.Descriptionno longer takesid.Choice.Rootowns that id and points the control'saria-describedbyat it; a consumeridreplaced 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
36aceb1Thanks @cody-dot-js! - Close accessibility gaps inCodeBlock.- The
<pre>no longer carriesaria-expanded, which is not valid on a<pre>. It now carriesdata-state="collapsed" | "expanded"while aCodeBlock.ExpanderButtonis composed, and the collapsed max height reads that attribute.CodeBlock.ExpanderButtonkeeps its ownaria-expandedandaria-controls. If you styled the collapsed state with thearia-collapsed:variant on the<pre>, usedata-[state=collapsed]:instead. - Line numbers are
aria-hidden, so a screen reader reads the code, not "1 const x". CodeBlock.CopyButtonannounces "Copied" through an always-mountedrole="status"element after a copy. A second copy inside the two-second reset window is announced again.CodeBlock.ExpanderButtonno longer typesasChild. The prop threw at runtime, because the button renders its own label and caret.
- The
-
#1478
36aceb1Thanks @cody-dot-js! - TheCombobox.Group,Combobox.GroupLabel, andCombobox.ItemValuesummaries in the built types now carry the same wrapping andaria-labelledbyguidance as the docs page. -
#1478
36aceb1Thanks @cody-dot-js! -Command.Shortcutnow lockstranslate="no", likeKbd, so a browser translator never renames a shortcut key.MetaKeyhides its glyph from assistive technology, so a screen reader reads its label ("Command" or "Control") once. Thetranslateprop is removed from its type. The search icon inCommand.Inputis hidden from assistive technology, and theCommand.SearchTrigger,Command.DialogRoot, andCommand.DialogContentsummaries in the built types now match their docs:SearchTriggerrenders no DOM of its own. -
#1478
36aceb1Thanks @cody-dot-js! - Fix theDataTableheader accessibility contract.DataTable.Headertakes an optionalcolumn. When the column is sorted, the cell carriesaria-sort="ascending"or"descending". Pass the samecolumnyou passDataTable.HeaderSortButton.DataTable.HeaderSortButtonkeeps 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 inaria-sorton the header cell anddata-sort-directionon the button.- A column that cannot sort (
disableSorting, orenableSorting: falseon the column) renders its label as plain text. It was a focusable button whose click did nothing. DataTable.ActionHeaderrenders a screen-reader-only "Actions" label when you pass no children, so the action column has a name.DataTable.EmptyRowspans the visible leaf columns, not every registered column, so a hidden column no longer stretches the empty state past the table.data-expandedonDataTable.Rowis now documented.
-
#1478
36aceb1Thanks @cody-dot-js! -DescriptionListexportsDescriptionListItemProps,DescriptionListLabelProps, andDescriptionListValuePropsalongsideDescriptionListProps. Adata-slotpassed toDescriptionList.Rootnow joins in front of"description-list"instead of replacing it, and every part documents itsdata-slot. The part summaries in the built types now match their declarations. -
#1478
36aceb1Thanks @cody-dot-js! -Dialog.DescriptionJSDoc: the namespace copy now states that the part renders adivand takesasChild, the same as the part declaration. -
#1478
36aceb1Thanks @cody-dot-js! -DropdownMenu.Shortcutlockstranslate="no", so a browser translation engine cannot rename a shortcut key; the prop is omitted from its type. TheDropdownMenu.SubTriggerJSDoc and docs now say it opens on hover, ArrowRight, or Enter, not on focus. -
#1478
36aceb1Thanks @cody-dot-js! -Empty.Titleis typed asComponentProps<"h3">, so it acceptsrefas the docs state. The docs now giveEmpty.Icon's real size (size-10) and say the icon is decorative by default. -
#1478
36aceb1Thanks @cody-dot-js! -HoverCardJSDoc: theContentandPortalnamespace summaries now match their parts, including that content mounts into the nearest layer container whencontaineris omitted. -
#1478
36aceb1Thanks @cody-dot-js! -IconandSvgOnlyare decorative by default. They now renderaria-hidden="true"unless the props or the svg element carry a name (aria-label,aria-labelledby, a<title>child, or Phosphor'salt), arole, or an explicitaria-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,ButtonwithasChild,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"andaria-labeland it stays exposed. -
#1478
36aceb1Thanks @cody-dot-js! - Remove the unusedcolormode fromInlineIconProps. No icon implemented the"auto"branded fill it described, and every consumer omitted the prop; the ngrok marks fill withcurrentColor, so thecolorCSS property sets the fill.Omit<InlineIconProps, "color">still compiles. -
#1478
36aceb1Thanks @cody-dot-js! -Inputno longer stampsdata-disabled="false"when it receivesaria-disabled="false". Thedata-disabled:variant matches attribute presence, so the stray value dimmed an enabled control. -
#1478
36aceb1Thanks @cody-dot-js! - TheKbddocs and JSDoc no longer recommendaria-labelon the<Kbd>. The<kbd>element has thegenericrole, 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 glypharia-hidden. -
#1478
36aceb1Thanks @cody-dot-js! -Labelstampsdata-disabledinstead ofaria-disabledfor its disabled style. A<label>has no role that supportsaria-disabled, so the attribute conveyed nothing to assistive technology. Style the disabled state withdata-disabled:; the oldaria-disabled:hook no longer matches. -
#1478
36aceb1Thanks @cody-dot-js! - TheList.Root,List.VirtualRoot, andList.Itemsummaries in the built types now match their declarations, and the props docs nameisItemDisabledamong the grid-only knobs thatListomits. -
#1478
36aceb1Thanks @cody-dot-js! -Mainno longer acceptsidortabIndex. It always stampsid="main"andtabIndex={-1}, becauseSkipToMainLinktargets#mainand focuses it, and a value passed through props was silently discarded before this change. -
#1478
36aceb1Thanks @cody-dot-js! -MediaObject.Root,MediaObject.Media, andMediaObject.Contentnow forward every<div>prop. Before this change they kept onlyclassName,style,children, andref, so anid, anaria-*attribute, adata-*attribute, or anonClickwas dropped in silence while the docs said "All props from div". Adata-slotchain from anasChildancestor joins ahead of each part's own slot name instead of replacing it. -
#1478
36aceb1Thanks @cody-dot-js! -MultiSelecttags now use a valid ARIA structure.MultiSelect.TagValuesrenders the tags inside arole="list"named "Selected values" (setaria-labelto change it), and eachMultiSelect.Tagis arole="listitem"that marks itselfdata-activewhile it has focus. Before this change every tag was arole="option"with no listbox around it, and the option role's presentational children flattened the "Remove …" button out of the accessibility tree.MultiSelect.Triggeris nowrole="presentation"instead of an unnamedrole="group"; the input's label names the control.If you render custom tags through the
MultiSelect.TagValuesrender function, give themrole="listitem", and query them by that role or bydata-slot="multi-select-tag"in tests. -
#1478
36aceb1Thanks @cody-dot-js! -OtpInput.Rootdocuments its accessible-name contract. The hidden input needs a name: inside aField.Item,Field.Labelsupplies it; on its own, passaria-label. The docs examples that render the control on its own now do so. -
#1478
36aceb1Thanks @cody-dot-js! -CursorPagination.PageSizeSelectnames 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. Passaria-labelto override the name. The deadvalueattribute on the trigger button is gone.useOffsetPagination'sgoToLastPagestays on page 1 for an empty list. It set the 1-indexedcurrentPageto 0 and theoffsetto a negative number. -
#1478
36aceb1Thanks @cody-dot-js! -PopoverJSDoc: each namespace member's summary now matches its part.Popover.Triggeropens on click; the namespace copy claimed it also opened on focus. -
#1478
36aceb1Thanks @cody-dot-js! - Document the accessible name contract forProgressBarandProgressDonut. Both renderrole="progressbar", so they need a name: passaria-label, oraria-labelledbythat points at a visible label. Every docs and JSDoc example now does. The JSDoc and docs also state that avalueoutside0..maxrenders as indeterminate, list the emitted data attributes, drop a staleviewBoxrationale fromstrokeWidth, and correct theProgressDonut.Indicatorexamples to set the color withclassName, not the SVGcolorattribute. -
#1478
36aceb1Thanks @cody-dot-js! -QrCode.Frameis now an image to assistive technology. It always carriesrole="img"and takesaria-labeloraria-labelledbyfor 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.roleis no longer aFrameprop. -
#1478
36aceb1Thanks @cody-dot-js! -RadioGroupaccessibility and documentation fixes.RadioGroup.Itemshows a focus ring when it renders a customRadioGroup.Indicatoror 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-describedbyfromField.Control. Headless UI strips the value, so the prop was dead; the JSDoc already said the attribute does not propagate. RadioGroup.Indicatordrops anamethatChoice.Indicatorinjects for a control, so the presentational<div>no longer carries an invalid attribute.- The
RadioGroup.InputSandboxJSDoc 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 adiv[role="radio"]; the group renders one hidden input for form submission when it has aname. The examples drop<label htmlFor>pairs, which cannot bind to adiv[role="radio"], and every standalone group carries an accessible name.
-
#1478
36aceb1Thanks @cody-dot-js! -SandboxedOnClickwithasChildno longer stampsrole="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
36aceb1Thanks @cody-dot-js! -Select.Root'sonBlurnow fires when the trigger button blurs. Before this change the prop was stored and never called, so a form library'sfield.handleBlurnever ran for aSelect. It runs after anyonBluryou pass onSelect.Trigger.The docs now list
Select.Item'siconprop andSelect.Content'spositionprop with its"popper"default, and theSelect.RootandSelect.Triggersummaries in the built types carry the same validation andField.Controlguidance as the docs page. -
#1478
36aceb1Thanks @cody-dot-js! - The search icon inSelectableList.Filteris hidden from assistive technology. EverySelectableListpart summary in the built types now matches its declaration, so hover documentation and the docs page say the same thing. -
#1478
36aceb1Thanks @cody-dot-js! -HorizontalSeparatorGroupacceptsref, as the docs said it did. Its props are nowComponentProps<"div">instead ofHTMLAttributes<HTMLDivElement>. The data attributes both parts emit (data-orientation,data-separator,data-horizontal-separator-group, and eachdata-slot) are now documented in the JSDoc and on the docs page. -
#1478
36aceb1Thanks @cody-dot-js! -SheetJSDoc and docs now say the sheet slides in from the left or right edge.sideaccepts"left" | "right"only; the docs listed"top"and"bottom", which the type never accepted. -
#1478
36aceb1Thanks @cody-dot-js! -Skeletonrendersaria-hidden="true"by default, as its docs already described. Passaria-hidden={false}to opt out, whichBreadcrumb.Skeletondoes because itsrole="status"announcer lives inside the bar. The props type now usesOmitto dropchildren; the earlierExcludewas a no-op on an object type. -
#1478
36aceb1Thanks @cody-dot-js! -SkipToMainLinkjoins adata-slotyou pass with its own"skip-to-main-link"instead of replacing it, like the layout parts do. The focus call no longer passespreventScroll, so a target below the fold scrolls into view when the link is activated and a sighted keyboard user sees where focus went. -
#1478
36aceb1Thanks @cody-dot-js! -Sliderdocumentation matches the code. Thecolordefault is"bg-accent-600", not"bg-accent-500";minStepsBetweenThumbsdefaults to1, not0;showTicksspreads one tick perstepevenly across the track, so the ticks do not land on the step values when the range is not a multiple ofstep; andaria-labelledbynames every thumb with the same text, so a range slider should preferaria-label, which derives a name per thumb. -
#1478
36aceb1Thanks @cody-dot-js! - Document thedata-slot="split-button"anddata-sizeattributes thatSplitButton.Rootemits, in the JSDoc and the docs API reference. -
#1478
36aceb1Thanks @cody-dot-js! - A click on a read-onlySwitchnow reaches ancestor click handlers. The guard still callspreventDefault, which Radix reads to skip the toggle, but no longer callsstopPropagation, so a clickable row around a read-only switch gets its click. -
#1478
36aceb1Thanks @cody-dot-js! -Table.Headerdefaultsscopeto"col". Passscope="row"for a header cell inside a body row. The docs now say thatTable.Rootattachesrefto its inner scroll container and thatTable.Captionis the table's accessible name. -
#1478
36aceb1Thanks @cody-dot-js! -Tabs.TriggerwithasChildno longer forcestabIndex={0}onto its child. Radix rotatestabIndexso 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.Listno 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-centerasChildlink moved out from under the pointer and never navigated. Keyboard focus still scrolls the focused tab to the center. -
#1478
36aceb1Thanks @cody-dot-js! -ThemeDropdownMenuRadioGroupnow names itsrole="group": "Theme" by default,aria-labelto rename it, oraria-labelledbyto point at a visible heading. Before this change the group had no name. The pre-hydrationSkeletoninsideThemeSwitcher.Triggerrenders as a<span>, so the server HTML no longer puts a<div>inside a<button>. TheThemeSwitcher.TriggerandThemeSwitcher.Contentsummaries in the built types now match their declarations. -
#1478
36aceb1Thanks @cody-dot-js! -useThemethrows outside aThemeProvider, 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
36aceb1Thanks @cody-dot-js! - -Toast.Iconrenders a customsvgforintent="info". Before this change the info branch ignoredsvgand always drew the default icon.Toasterconverts aduration_msof0or less toNumber.POSITIVE_INFINITY, as the docs describe and asmakeToastalready did. Before this change0fell back to sonner's 4000ms default and a negative value dismissed at once.Toast.Rootno longer advertisesasChildin its props type. The root renders the intent accent bar next tochildren, so a slot always threw at runtime.
-
#1478
36aceb1Thanks @cody-dot-js! -Tooltip.Contentno longer advertisesasChildin its props type. The content renders its own arrow next tochildren, so a slot always threw at runtime.Tooltip.Rootno longer stamps adata-slot, because the Radix root renders no DOM for it to land on. -
#1487
467350fThanks @cody-dot-js! - Whentype,value, and the set of open sections are unchanged,Accordion.Rootnow keeps its context identity across parent renders. Before this change, every parent render rebuilt the context, so:- every
Accordion.Itemrecomputed its state, - every
Accordion.Contentremoved and re-added itsbeforematchlistener, and - a
memo()wrapper around an item never skipped a render.
The accordion still calls the latest
onValueChangeyou pass. - every
-
#1487
467350fThanks @cody-dot-js! -AlertCenter.Barnow 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 anddata-alert-iduntil the exit slide completes. -
#1486
2824f38Thanks @cody-dot-js! -AlertCenter.Itemtakes an optionalorder, so you can decide which of two same-intent alerts the bar shows.The center ranks alerts by
intent, then byorder, 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.intentstill decides the tier: awarningwithorder={0}never outranks adanger.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
orderties with every other undeclared item, so the comparator falls through to arrival. See the AlertCenter.Item API. -
#1485
1d64033Thanks @cody-dot-js! -Alert.DismissIconButtonnow 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 youronClickhandler or a layout effect places focus first, that choice stands.AlertCenter.DismissIconButtonis 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
26161caThanks @cody-dot-js! - Document how to announce a dynamicAlert.Alert.Roothas no ARIA role of its own, so an alert that your code inserts after an action is silent to a screen reader. Passrole="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 withtabIndex={-1}. For a less urgent message, passrole="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
9dba9eaThanks @cody-dot-js! - Bump runtime dependencies:@ariakit/reactto 0.4.39 and@tanstack/react-virtualto 3.14.11.The
@ariakit/reactbump changes the DOM ofMultiSelect.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 thelistboxelement, where alistboxmay not own abutton. It now renders next to thelistbox, 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 thelistboxsees the change.The
@ariakit/reactbump also fixes a one-frame stale highlight inComboboxandMultiSelect: withautoSelecton, the previously active option no longer stays highlighted for one frame after the filtered list changes.The
@tanstack/react-virtualbump reachesList.VirtualRootandSelectableList.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
fc58b80Thanks @dependabot! - Bump build and runtime dependencies:tsdownand@tsdown/cssto 0.23.0, andoxc-parserto 0.149.0.tsdownbuilds all three packages, so every published bundle and.d.tsfile now comes from the new bundler. The public API does not change.oxc-parseris a runtime dependency of@ngrok/mantle-vite-pluginsand@ngrok/mantle-server-syntax-highlighter, so an install of either package resolves the new version. -
#1487
467350fThanks @cody-dot-js! - TheCalendarJSDoc and docs page now state the server-rendering contract. If you omittoday,DayPickerreads the clock during render, once on the server and again in the browser. If the two clocks or time zones disagree on the date, thetodayhighlight and itsToday, …label land on different days. React then reports a hydration mismatch. Under SSR, passtodayanddefaultMonth(ormonth) from one request-scoped date. Nothing changes at runtime. -
#1487
467350fThanks @cody-dot-js! -Calendarnow passesDayPickerone stablecomponentsobject, and aclassNamesobject that changes only when yourclassName,classNames, ormodechanges. Before this change, everyCalendarrender handedDayPickertwo new objects, soDayPickerrebuilt 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 yourclassNamesandcomponentsoverrides behave as before. -
#1487
467350fThanks @cody-dot-js! -LineChart,AreaChart, andScatterPlotread data faster on continuous x scales. On a"time"scale the chart used to keep oneDateobject per row for the life of the data, so a streaming chart with 100,000 rows carried 100,000Dateobjects across every append. The chart now builds theDate(or the number on a"linear"scale) when it publishes a hover snapshot or anonDatumActivateevent, so each data update is faster and no per-rowDatestays resident. ThexValueyou receive is unchanged in type and value; it is now a freshDateper snapshot, so aDateyou mutate in a handler never changes what the tooltip shows next. Band and point scales keep the raw category values as before. -
#1487
467350fThanks @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
467350fThanks @cody-dot-js! - A chart no longer keeps an enter transition for every seriesdataKeyit 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
467350fThanks @cody-dot-js! - AyDomainchange on a chart no longer re-reads the data. ChangingyDomainonLineChart,AreaChart,BarChart, orScatterPlotused 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 drivesyDomainfrom state stays smooth on large data. Withanimateon, the axis still glides to the new domain. Withanimateoff, or under reduced motion, it snaps. -
#1487
467350fThanks @cody-dot-js! - The chartRoot, the tooltip surface, andCopyButtonno 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 atryblock. Behavior does not change. A consumerrefon aTooltippart still receives the tooltip element, honors a callback ref's cleanup, and resets an object ref tonullon unmount. -
#1487
467350fThanks @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
467350fThanks @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
467350fThanks @cody-dot-js! - A chart re-render with unchangedTooltipprops no longer re-registers the tooltip. Before this change, every render of a parent that composedBarChart.Tooltip,LineChart.Tooltip,AreaChart.Tooltip, orScatterPlot.Tooltiptore 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: alabelFormat,valueFormat,footer, orchildrenfunction, afooterelement, or anonClickhandler orstyleobject among the div props. Pass stable values to skip the republish. -
#1487
467350fThanks @cody-dot-js! -Checkboxnow sets the nativeindeterminateproperty in a layout effect, before the browser paints. Before this change, aCheckboxwithchecked="indeterminate"ordefaultChecked="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
467350fThanks @cody-dot-js! - A callbackrefonCodeBlock.Codeis 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 withnull. Before this change every re-render of the block, including eachCodeBlock.ExpanderButtonclick, called the ref withnulland then with the node again, so a ref that measures or observes the<pre>did its setup work several times per block. -
#1487
467350fThanks @cody-dot-js! - A collapsibleCodeBlockthat mounts on the client now paints collapsed on its first frame.CodeBlock.ExpanderButtonregisters withCodeBlock.Rootin a layout effect, so the<pre>carriesdata-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>withoutdata-state, so a server-rendered block collapses at hydration as before. -
#1487
467350fThanks @cody-dot-js! -CodeBlock.CopyButtonnow compiles under the React Compiler. Its click handler no longer callsonClickandonCopywith?.()inside thetryblock. The compiler cannot lower that shape, so it left the whole component uncompiled.CopyButtonnow gets the same compiler memoization as its siblings. Nothing changes at runtime:onClick,onCopy, andonCopyErrorfire as before. -
#1487
467350fThanks @cody-dot-js! -CodeBlock.ExpanderButtonnow carriesaria-controlsin the server HTML.CodeBlock.Rootgenerates 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 witharia-expandedbut noaria-controls. The<pre>id is still a generated value with no stable format. -
#1487
467350fThanks @cody-dot-js! -Combobox.Itemno longer wraps each item in a context provider that nothing read.Combobox.ItemValuetakes 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
467350fThanks @cody-dot-js! -Command.DialogRootnow readsonOpenChangethrough a ref, so the valueuseCommandDialog()returns keeps its identity across parent renders. Before this change, an inlineonOpenChangearrow gave that value a new identity on every parent render. Every consumer of the hook,Command.SearchTriggerandCommand.Inputincluded, re-rendered with it. The callback you pass still runs on every open and close, and the latest one always wins. -
#1487
467350fThanks @cody-dot-js! -Command.Emptyrenders 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 inlineCommand.Rootpalette 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
467350fThanks @cody-dot-js! - A callbackrefonCheckbox,Select.Root,Select.Trigger,Table.Root,Tabs.List, orTextAreanow 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 withnull. Before this change, every re-render of the component detached and re-attached the ref, so your callback ran withnulland then with the element again on each render. Arefthat measures the element, registers it, or starts an observer now does that work once. ASelect.Triggerre-render also no longer round-trips two state updates through the RadixSelectroot.Dialog.Content,Sheet.Content, andAlertDialog.Contentno longer pay an extra render pass on each re-render.If you pass a different
refon 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, andSandbaralready fired yourrefonce per mount, but they attached a swappedrefonly when the element next mounted. They now attach it at once too. -
#1483
636934fThanks @cody-dot-js! -CursorPagination.Rootaccepts a controlledpageSize. Pass it withonChangePageSizeto own the page size yourself, for example in a URL search param, andCursorPagination.PageSizeSelectshows the value you pass. Before this change the select was uncontrolled and seeded once fromdefaultPageSize, so a page size that changed outside the component, such as a browser history move that rewrote the URL, never reached the select.defaultPageSizestill works for the uncontrolled case; pass exactly one of the two.onChangePageSizeonRootalso runs in the uncontrolled case, so you can read the change without a callback onPageSizeSelect.CursorPagination.PageSizeSelectnow 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
DataTablepagination recipe, in its JSDoc and on both docs pages, now passes the table's live page size aspageSize, so the select follows atable.setPageSize()call from anywhere. -
#1486
2824f38Thanks @cody-dot-js! -DataTable.RowandDataTable.ActionCellnow guard a clickable row the way the docs always said a consumer should.DataTable.ActionCellstops a click inside it from reaching the row'sonClick, 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 forgottenonClick={(event) => event.stopPropagation()}navigated the user away when they opened the menu. Your ownonClickon the cell still runs, and the<td>keeps itsrole="cell". ThestopPropagationhandler in your code is now redundant and can go.DataTable.RowrunsonClickonly 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.Rowstampsdata-clickablewhenonClickis set, so a test or a stylesheet has a stable hook instead of thecursor-pointerclass.- A
Table.Bodyrow 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 inSandboxedOnClickwithasChildandallowClickEventDefault. See Navigating on row click.
-
#1487
467350fThanks @cody-dot-js! -Dialog.Content,Sheet.Content, andAlertDialog.Contentnow let Radix ownaria-describedby. When you render aDescriptionpart, the content'saria-describedbypoints at that description as soon as the content opens. When you render none, the content carries noaria-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 explicitaria-describedbyon the content still wins. -
#1477
26161caThanks @cody-dot-js! -Field.Itemnow takes anidfor the focusable control.Field.Controlsplats it onto the control andField.Labeluses it as the defaulthtmlFor, so a Playwright locator or an anchor link can find the control by a stable id. When omitted,Field.Itemgenerates a stable id as before. Anidon the control element itself is still overwritten:Field.Itemowns the control'sid,name,aria-*, andvalidation.Before this change, an
idonField.Itemlanded on its wrapping<div>. It now names the control, and the wrapper carries no id. This matchesChoice.Root, whoseidalso names the control. The migration guide is at mantle.ngrok.com/migrations/field-item-owns-control-id-migration. -
#1477
26161caThanks @cody-dot-js! -Field.Labelno longer takeshtmlFor, andField.Controlno longer takesid.Field.Itemowns the control id: itsidprop names the control,Field.Controlsplats it onto the focusable child, andField.Labelalways points itshtmlForat it. A label and its control can no longer drift apart. Outside aField.Item,Field.Labelrenders a<label>with noforattribute; useLabelfrom@ngrok/mantle/labelthere.Migration: delete every
htmlForonField.Labeland everyidonField.Control. Move a literal id that must stay stable toidon the surroundingField.Item. The full guide, with before and after examples, is at mantle.ngrok.com/migrations/field-item-owns-control-id-migration. -
#1487
467350fThanks @cody-dot-js! -Field.Item,Field.Errors, andField.ErrorListnow 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 noaria-errormessage, and the item has nodata-validation="error". For an error that renders on the server, setvalidation={messages.length > 0 && "error"}onField.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.Itemalso keeps its context identity stable whenvalidationis explicit. Before this change, the first message aField.ErrorsorField.ErrorListmounted or unmounted rebuilt the context, soField.Label,Field.Control, andField.Descriptionre-rendered on that transition. The rendered DOM and ARIA output are unchanged. -
#1487
467350fThanks @cody-dot-js! -isCountryCodenow checks aSetbuilt once at module load instead of scanning the 751-entrycountryCodesarray on every call, so narrowing one code per table row no longer costs a linear scan per row. The results are unchanged: a string incountryCodesreturnstrue; a lowercase code, a number, an array,null, orundefinedreturnsfalse. -
#1487
467350fThanks @cody-dot-js! -MetaKey,Command.SearchTrigger, andSidebar.Triggernow 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
467350fThanks @cody-dot-js! -useBreakpointnow 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
467350fThanks @cody-dot-js! -useMatchesMediaQuerynow constructs oneMediaQueryListper hook instance and reuses it across renders and change notifications. Before this change every render calledwindow.matchMediaagain, which parsed the query and allocated a new list each time. The returned value and the change subscription behave as before. -
#1487
467350fThanks @cody-dot-js! -usePrefersReducedMotionnow returns the real preference in the first render of a client mount. Before this change every mount renderedtrue(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 returntrue, so nothing animates before hydration. -
#1487
467350fThanks @cody-dot-js! -useUndoRedonow derivescanUndoandcanRedofrom state instead of reading its history refs during render. The history itself still lives in refs, so twoundoorredocalls 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
467350fThanks @cody-dot-js! - A callbackrefpassed toInputorInputCapturenow 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 withnull. Before this change, every re-render of the component detached and re-attached the ref, so the callback sawnulland 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
467350fThanks @cody-dot-js! -isInputfrom@ngrok/mantle/inputnow returnsfalseon the server. Before this change, a call with a non-null value in a loader, an action, or a render helper threwReferenceError: HTMLInputElement is not defined, because Node declares noHTMLInputElement. In the browser nothing changes. -
#1487
467350fThanks @cody-dot-js! -MultiSelect.Contentnow 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 adata-mantle-modal-contentancestor after mount. InsideDialog,Sheet, andAlertDialogthe result is the same, because each renders its content inside its layer container. If your own overlay carriesdata-mantle-modal-contentoutside a mantle overlay, Ariakit's lock now stays on there; passpreventBodyScroll={false}to turn it off. As before,preventBodyScrolloverrides the default in either direction. -
#1487
467350fThanks @cody-dot-js! -MultiSelect.Itemnow reads thelockedValuesofMultiSelect.TagValuesfrom React state thatMultiSelect.Rootowns, instead of a ref thatTagValueswrote 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 anItemkept 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 onMultiSelect.Triggerrendering beforeMultiSelect.Content.TagValuesandItemnow compile under the React Compiler; the render-time ref writes and reads made the compiler skip both parts before. -
#1487
467350fThanks @cody-dot-js! - WhenMultiSelect.TagValuesunmounts, it now drops its keyboard handler and its locks. Before,MultiSelect.Inputkept calling the handler from the last render ofTagValues, so Backspace on an empty input removed the value that was last at that time.MultiSelect.Itemalso kept honoring locks for a tag list that no longer existed. -
#1483
636934fThanks @cody-dot-js! -useOffsetPaginationacceptsdefaultPage, a controlledpagewithonPageChange, andresetPageOnListSizeChange. PasspageandonPageChangeto keep the page outside the hook, for example in the URL; the hook callsonPageChangewith the next page and reads the page back from the prop. SetresetPageOnListSizeChange: falseto stay on the current page whenlistSizechanges:currentPageclamps to the newtotalPagesinstead of returning to page 1, so a background refetch that changes the list length no longer moves the user. The default staystrue.currentPagenormalizes apageordefaultPage: a value past the last page clamps to it, a fraction rounds down, andNaNreads as 1, so a malformed URL param cannot produce aNaNoffset. -
#1487
467350fThanks @cody-dot-js! -PasswordInputnow treatsshowValueas a controlled prop, as its docs state. When you passshowValue, the toggle button no longer flips the visibility on its own: it callsonValueVisibilityChangewith the next value, and the input follows the prop. Before this change, a click revealed the value even whileshowValuestayedfalse, so the input drifted from the prop until the prop changed. AshowValuechange now also applies in one render, with no intermediate frame that shows the oldtype,aria-pressed, or icon.If you pass a static
showValueand rely on the built-in toggle to flip the visibility, passonValueVisibilityChangeand write the next value back intoshowValue, or omitshowValueto keep the toggle uncontrolled. -
#1477
26161caThanks @cody-dot-js! - Make thePasswordInputvisibility toggle a keyboard-reachable toggle button, and give the input a stableid.- 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-pressedfor its state andaria-controlsthat points at the input. - When
disabledis 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
idyou pass, or a generated one when you pass none. Inside aField.Item, the id comes fromField.Item'sidprop. - The toggle stamps
data-slot="password-input-toggle".
Inputno 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.
- The toggle is now in the tab order. Enter and Space toggle visibility and keep focus on the button. It was
-
#1487
467350fThanks @cody-dot-js! -ProgressDonut.Rootnow compiles under the React Compiler. It builds the track circle'sstyleobject before the contextuseMemo, 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--radiusvariable are the same. -
#1486
2824f38Thanks @cody-dot-js! - AChoice.Descriptioninside aRadioGroupitem is now the radio'saria-describedby, and theChoice.Titlealone is its accessible name.role="radio"flattens its children, so before this change aChoice.TitleandChoice.Descriptioninside a radio merged into one name, such as "Free Up to 3 projects and 1 member." NowChoice.TitleandChoice.Descriptionregister their ids with the radio item, which pointsaria-labelledbyat the title andaria-describedbyat the description. This works in every item variant:RadioGroup.Item,RadioGroup.ListItem,RadioGroup.Card, andRadioGroup.Button. AChoicenext to a checkbox or a switch is unchanged, andChoice.Titlenow carries anid, which defaults to a generated one.The same change lets a radio item inside
Field.Controlpick up the field'saria-describedby, soField.DescriptionandField.Errorsnow describe each radio in that composition. Before, Headless UI'sRadiowrote its own emptyaria-describedbyover the value, and the docs said the attribute did not propagate. Each item variant now renders its own element through Headless UI'sasprop and stamps the ids after that spread. See Rich option layout with Choice. -
#1487
467350fThanks @cody-dot-js! -SelectableList.Rootno longer re-renders every mounted row when a parent render passes a new inlineonValueChangeoronQueryChange. 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 whenvalue,options, andquerywere unchanged.Rootstill calls the latestonValueChangeandonQueryChangeyou pass. -
#1487
467350fThanks @cody-dot-js! -Sidebar.ItemButtonandSidebar.SearchTriggernow merge their shared row classes through thecxargument 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 renderedclassNameis unchanged. -
#1487
467350fThanks @cody-dot-js! - TheTableandTabssource 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 seesTableandTabsas client components. -
#1487
467350fThanks @cody-dot-js! -ThemeProvidernow compiles under the React Compiler. Its effect cleanup held optional chaining inside atryblock. The compiler cannot lower that shape, so it skipped the whole component. The cleanup now calls a module function that owns thetry. Behavior is unchanged: the cross-tabBroadcastChannelstill closes on unmount, and aclose()that throws is still swallowed. -
#1487
467350fThanks @cody-dot-js! -ThemeProvidernow takes an optionalssrCookieprop: the theme cookie pair fromextractThemeCookiethatMantleStyleSheetsanduseInitialHtmlThemePropsalready accept. On the server, the provider seedsuseThemeanduseAppliedThemefrom it, so the hydrated client renders the same markup as the server. Before this change the server always renderedsystem. 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,AutoThemeIconkept the server's sun glyph after hydration. The client still readsdocument.cookie, so an omittedssrCookiebehaves as before. Pass the value from your root loader:<ThemeProvider ssrCookie={loaderData?.ssrCookie}>. -
#1487
467350fThanks @cody-dot-js! -useCallbackReffrom@ngrok/mantle/hooksnow accepts a callback with typed parameters, such as(values: string[]) => void. Before this change, its generic constraint rejected any callback whose parameters were narrower thanunknown, so only a callback with no parameters, or with parameters typedunknown, compiled. The returned function keeps the exact signature of the callback you pass. -
#1487
467350fThanks @cody-dot-js! -List.VirtualRootandSelectableList.VirtualViewportnow render the first slice of rows in the server HTML. Before this change, the server emitted the collection chrome with its full height andaria-rowcountbut zero rows, so a server-rendered long list showed a tall empty box until hydration and no rows at all without JavaScript.estimateItemHeightandoverscansize 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
467350fThanks @cody-dot-js! -List.VirtualRootandSelectableList.VirtualViewportno 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 fromestimateItemHeightstill re-renders the mounted rows once on the frame that measures it. Row keys still follow yourkeyprops across a reorder or filter.
0.84.1
Patch Changes
-
#1470
b5f83e8Thanks @cody-dot-js! -Choice.Descriptionnow forwards its click to the siblingChoice.Label, so the whole content column toggles the control. The accessible name stays the label alone, and the description stays wired througharia-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 aListrow applies to its own click-to-activate. It runs after a consumeronClick, soevent.preventDefault()cancels it. After it forwards, it marks the click handled, so a click on aSelectableListrow's description toggles the row once. When the choice usesChoice.Title, the description forwards nothing, because an ancestor already owns that click. The description also shows the pointer cursor when aChoice.Labelprecedes it.ListandSelectableListrows now also defer a bare row click that lands inside a<details>, so a<summary>toggle no longer activates the row. -
#1471
b5dd7a6Thanks @cody-dot-js! -useLocalStorageanduseSessionStoragenow guard every storage access. Before, asetItemthat 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
storageevent 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
b5f83e8Thanks @cody-dot-js! -Slotno longer erases a component child's owndata-slot. It passeddata-slot: undefinedto the child when neither side set one, and a part that spreads its props after its owndata-slotlost the attribute.Field.Controlwraps its child inSlot, so aCheckboxorChoice.Rootinside one rendered withoutdata-slot="checkbox"ordata-slot="choice".
0.84.0
Minor Changes
-
#1463
178b4d3Thanks @cody-dot-js! - AddLiveRegion, a persistent, visually hidden live region that announces its text to screen readers when the text changes. Thepolitenessprop picks the urgency:"polite"(the default) rendersrole="status"andaria-live="polite", and"assertive"rendersrole="alert"alone, because a redundantaria-livemakes VoiceOver on iOS announce the message twice. The region stampsaria-atomic="true"anddata-slot="live-region", takesasChild, and treats the derivedroleandaria-liveas 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
178b4d3Thanks @cody-dot-js! - AddVisuallyHidden, a primitive that renders content for screen readers without showing it. It renders a<span>clipped to a one-pixel box (neverdisplay: none), stampsdata-slot="visually-hidden", and takesasChildto 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 usearia-labelthere instead.Docs: https://mantle.ngrok.com/components/primitives/visually-hidden
Patch Changes
-
#1459
edbca2cThanks @dependabot! - Bump runtime dependencies:@ariakit/reactto 0.4.38.The ariakit bump changes keyboard activation in mantle's ariakit-backed parts (
Combobox,MultiSelect): the click thatEnterorSpacesynthesizes on an item is now aPointerEventwithpointerId: -1and an emptypointerType, the same shape browsers dispatch for a click no pointer caused, so anonClickhandler that readsevent.pointerTypeor checksinstanceof PointerEventnow 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
edbca2cThanks @dependabot! -Sandbarnow attaches a rejection handler to the shake animation'sfinishedpromise. Canceling a shake (a rapid second blocked attempt, or unmount mid-wiggle) rejects that promise with anAbortError, which surfaced as an unhandled rejection in test environments such as happy-dom 20.12.0. -
#1464
86758b4Thanks @cody-dot-js! - Update thesource-all.cssheader comment: it no longer recommends the retiredmantleTwSourcePluginfor apps that use a subset of mantle. Importsource-all.cssalongsidemantle.cssto register mantle's Tailwind sources.
0.83.9
Patch Changes
-
#1450
32a7532Thanks @cody-dot-js! -IconButtonandPasswordInput's visibility toggle now carry their accessible name asaria-labelinstead 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 announcearia-labelthe same way, so nothing changes for them.IconButtonPropsnow rejectsaria-label(typednever): thelabelprop is the single way to name the button. Components built onIconButton(ThemeSwitcher.Trigger,Sidebar.Trigger,DataTable.RowExpandButton,CodeBlock.CopyButton, and the chart copy buttons) inherit the fix. -
#1427
00c346cThanks @dependabot! - Bump runtime dependencies:@ariakit/reactto 0.4.37,@tanstack/react-virtualto 3.14.10,input-otpto 1.5.0, andsonnerto 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 explicitundefinednow behaves like an omitted prop, so the component keeps its computed default.The
input-otpbump hardensOTPInput: the browser no longer spellchecks the code, a 16px font-size fallback prevents the iOS focus zoom, and the library's own container now carriestranslate="no", which backs up the lock mantle already puts on each slot.
0.83.8
Patch Changes
-
#1443
d17713dThanks @cody-dot-js! -CodeBlock.Codeno longer bounces at the ends of its horizontal scroll range —overscroll-behavior-x: nonealso keeps the scroll from chaining to the page. -
#1443
d17713dThanks @cody-dot-js! -CodeBlock.Codepins 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
d17713dThanks @cody-dot-js! -CodeBlock.Codeno longer shows a horizontal scrollbar when every line fits the container.Every rendered line reserved
3.5remof 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 aCodeBlock.CopyButtonis rendered. Every line keeps a1remreserve, so a line at the end of the scroll range stays clear of the container's edge. -
#1444
408092fThanks @cody-dot-js! - Stack floating UI in two tiers so a background popover can never paint over an open dialog.Overlays —
Dialog,AlertDialog,Sheet, andCommand.DialogRoot— now render atz-60, above the float tier. Floats —Popover,Tooltip,Select,DropdownMenu, andHoverCard— stay atz-50and portal into the nearest overlay's positioner instead ofdocument.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 sharedz-50and 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", anddata-slot="sheet-positioner".An explicit
containeronDialog.PortalorHoverCard.Portalnow 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-50that must paint above an open mantle overlay needsz-60or higher. - CSS that selects a float's portal as a direct
bodychild (for examplebody > [data-radix-popper-content-wrapper]) no longer matches when the float is composed inside an overlay. Select the float'sdata-slotinstead.
Combobox.Content(renders in place),MultiSelect.Content(portals into the overlay'sdata-mantle-modal-content), andToast(sonner's own viewport, far above both tiers) keep their placement. - Custom fixed chrome at
0.83.7
Patch Changes
-
#1441
d9829f3Thanks @forzalupo! -Sidebarships a narrower default expanded panel:--sidebar-widthis now13rem(208px), down from16rem(256px).--sidebar-row-widthfollows to12rem(192px), so menus floored at that token stay flush with a row.Override
--sidebar-width(onSidebar.Navor at:root) if you want the previous width back. See https://mantle.ngrok.com/components/navigation/sidebar#width.
0.83.6
Patch Changes
-
#1432
391093aThanks @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 (SettingsinSettings > General). It renders a muted<span>withdata-slot="breadcrumb-label", nohref, and noaria-current, and supportsasChildlike 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-onlyrole="status"announcement (labeldefaults to "Loading breadcrumbs…"). The bar isw-24by default; pass aw-*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/breadcrumband router-agnostic: therouteBreadcrumbfactory creates link, label, and content crumbs as a discriminatedCrumbunion;buildCrumbsderives aResolvedCrumbtrail from any matches carryingid,pathname, andhandle(react-router'sUIMatchsatisfies this structurally);hasBreadcrumbis the type guard;BreadcrumbHandle<TMatch>types a route'shandleexport.
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 toBreadcrumb.Skeletonwhile the query is pending.
0.83.5
Patch Changes
-
#1412
3d5b964Thanks @cody-dot-js! -IconButtongains thefilledappearance, and narrowsintentto"neutral".Before this,
IconButtonstopped atghostandoutlined. 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 tooutlinedor hand-rolled the fill.filledcloses that:appearance="filled" intent="neutral"is the same solid box on both components.intentnow 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"andintent="danger"are a type error onIconButtonand on the wrappers that forward the prop —Dialog.CloseIconButton,Sheet.CloseIconButton,Field.HelpTrigger,DataTable.ExpandButton, andSidebar.Trigger. Reach forButtonwhere 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-intentis still stamped, always"neutral".linkstays 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'soutlined+neutralkept its accent border while focused and hovered, but dropped back to the resting border the moment the button was pressed;IconButtonheld the accent border through the press.Buttonnow holds it too, so a focused button and a focused icon button draw the same border for the whole click. -
#1425
25ed46cThanks @cody-dot-js! -terraformandtfjoin the supported code block languages.mantleCode("terraform"), Markdown fences, and the server highlighter render HCL with Shiki's Terraform grammar, which ships in the existingshikidependency.Terraform blocks fold as bracket pairs —
resource "type" "name" { … }, object values, and multi-line lists all get gutter toggles. Indentation normalizes to spaces, which matchesterraform fmt. -
#1419
5ee7365Thanks @forzalupo! -TextAreanow renders its default appearance at the same font size asInputandSelect, and itsmonospacedappearance a step smaller.The default (non-monospaced)
TextAreaonly setpointer-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 thetext-sm(14px) thatInputandSelectuse. Adding the missingtext-smbase brings it in line: 14px on desktop, 16px on touch, matching the other form controls.The
monospacedappearance drops from the arbitrary0.8125rem/0.9375remsizes totext-xs/5on desktop (12px font with thetext-sm1.25remline-height, so its rows and baseline stay aligned with the default appearance) andpointer-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
272814dThanks @cody-dot-js! -Alert.ExpandButtonrenders its caret throughButton'siconslot, 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 themin-widththat stops a two-digit count from shifting the label. Its own padding moves tops-1.5 pe-1, which keeps the caret side0.125remtighter 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-*orpe-*longhand, and apx-*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
272814dThanks @cody-dot-js! -ButtonandBadgerender their label slot withdisplay: contents, so the wrapper 0.83.3 added changes no layout.That release wrapped
childrenin 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'sgapbetween them, and amin-widthon a child lost the formatting context that held it.Alert.ExpandButtonrendered+3moreinstead of+3 more ⌄.The span now generates no box. Every child is a flex item of the button or the badge again, the
gapfalls 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'sinsertBeforestill 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:
flex-1andmin-w-0reach that span only while it is a flex item of the button, so the wrapper's box dropped both and the label pushed pastmax-w-48instead of clamping.Anchoris unchanged. Its label span never changed layout, because an anchor lays its children out in inline flow. -
#1409
272814dThanks @cody-dot-js! -OtpInput.Slotnow renderstranslate="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 raisesNotFoundError— 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
translatefrom 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.KbdandCodeBlock.Codealready lock the attribute the same way.
0.83.3
Patch Changes
-
#1405
aebf20cThanks @cody-dot-js! -PopoverandHoverCardnow export anArrowpart — a tip that points from the content at what it is anchored to. Render it as a child of the content: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
alignper anchor to land on the right row at all.Mantle styles the tip to read as a continuation of the content's edge.
Popover.ContentandHoverCard.Contentboth paint abg-popoverfill behind aborder-popoveredge, and Radix renders the arrow as ansvg, wherebg-*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-colorand--shadow-first-opacitytokens the content'sshadow-mduses.box-shadowstops at the content's border, so a tip without one reads as pasted on. Aclip-pathtrims 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.ContentandHoverCard.Contentnow setposition: relative. The arrow's wrapper is absolutely positioned, and the open animation'sscalemade 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'sz-50now 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-slotispopover-arrowandhover-card-arrow.widthdefaults to14andheightto7; Radix adds the measured height tosideOffset, so the tip landssideOffsetpixels from the anchor. Neither part takesasChild, because a swapped element loses the two-layer shape — restyle withclassName(fill-*for the surface, and target thepolylinechild for the edge), remembering that the part already setsfilterandclip-path.Tooltipneeds no new part:Tooltip.Contentalready renders its own arrow. -
#1405
aebf20cThanks @cody-dot-js! -Code,Kbd,CodeBlock.Code, andCodeBlock.Titlenow rendertranslate="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.translateis 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 callsremoveChildon a node that moved and throwsNotFoundError, 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.
KbdandCodeBlock.Codelock it. A key and a block of code are never translatable, so both omittranslatefrom 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.CodeandCodeBlock.Titlekeep the prop.Codealso styles terms that are not code, andCodeBlock.Titletakes arbitrary children, sotranslate="no"is a default rather than a rule: passtranslate="yes"when the content is prose.CodeBlock.Titleis included because the title usually names a file —example.ts— and a translated filename names a file that does not exist. -
#1408
0f50685Thanks @cody-dot-js! - Fix a crash on browser-translated pages.ButtonthrewDOMException: Failed to execute 'insertBefore' on 'Node'the momentisLoadingturned on, which tore down the React root and blanked the page.Badge,Anchor,DataTable.HeaderSortButton, andDataTable.ActionHeadershared 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>. AninsertBeforeaimed at that node then names a child its parent no longer owns, and the DOM raisesNotFoundError. Every affected component rendered a conditional element immediately before barechildren, so the first click of any submit button threw before the loading spinner could render.Button,Badge, andAnchornow renderchildreninside a span carrying a newdata-slot—button-label,badge-label, andanchor-label. Each one is public API: target it to style the label, for example withmin-w-0 truncateto clamp a long one.DataTable.HeaderSortButtonkeeps its screen-reader sort announcer mounted at all times, andDataTable.ActionHeaderrenders its sticky-column indicator afterchildren.One layout note. The label is a single flex item, so
Button's andBadge'sgapfalls between the icon slot and the label, never between twochildren. A call site that passed an icon as a child instead of throughiconloses 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
60057c6Thanks @cody-dot-js! -BarChartbars 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.tspins the rule andchart/bar-thickness.browser.test.tsxmeasures the painted bars.There is no
barSizeprop, and this adds none: bar thickness stays a property of the layout.Docs: https://mantle.ngrok.com/components/charts/bar-chart#barchartbar
-
#1398
60057c6Thanks @cody-dot-js! -BarChart.Root,LineChart.Root,AreaChart.Root, andScatterPlot.Rootnow paint adecorativechart neutral: every series wears the new--color-chart-decorativefill 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
seriesSlotand a series' owncolor, and it reaches the canvas, the legend swatches, and the tooltip strokes from one place. Slots keep resolving underneath it, so a chart that leavesdecorativegets its palette back unshuffled.The fill is also what keeps an overlaid message readable, with no help from the call site.
Empty.Descriptionsits ontext-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-decorativeis 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.tsxmeasures every theme, andchart/decorative-paint.browser.test.tsxsamples the painted canvas.--color-chart-decorativeis a new token, one per theme, aliased to that theme'sneutral-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-mutederases the chart.
Every
decorativechart picks the fill up with no call-site change. Drop anopacity-*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
b53e6fbThanks @cody-dot-js! -Sidebar.Headernow stacks rows below its alignment band, so a pinned search row can sit under the product switcher without resizing theAppLayout.Headertoolbar.--sidebar-header-heightdid two jobs at once: it fixed the header's height, and it named the band anAppLayout.Headertoolbar matches. Stacking a switcher row and aSidebar.SearchTriggerin 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:
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 ofSidebar.Body's padding. The header reserves no scrollbar gutter, so on a platform whose scrollbars take space the row runs wider than the rows insideSidebar.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-heightoverride 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 aclassNamethat replaces the header'sdisplaydrops the band with it.
- Delete the
0.83.1
Patch Changes
-
#1395
f5a47f4Thanks @dependabot! - Bump the@ariakit/reactruntime dependency from 0.4.35 to 0.4.36.Combobox.RootandMultiSelect.Rootpass their props to Ariakit'sComboboxProvider, and their prop types are Ariakit'sComboboxProviderProps, so 0.4.36's store changes reach both:- The combobox store now separates the input text from the selection.
inputValue,defaultInputValue, andsetInputValuecontrol the query text. Ariakit marks the oldervalue,defaultValue, andsetValuenames deprecated — they still work, and code that controls aCombobox.Rootquery throughvalue/setValueshould move toinputValue/setInputValue. - The store gained
selectOnMove, which selects the active item as the user moves through the list with the popover open. It defaults tofalse, so no existing call site changes. Combobox.ContentandMultiSelect.Contentare AriakitComboboxPopovers, 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
getPersistentElementscallback, 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.
- The combobox store now separates the input text from the selection.
-
#1392
ed5b1e6Thanks @cody-dot-js! - AddappearancetoDialog.Content, so a full-page or edge-to-edge dialog is one prop instead of a class recipe.- Every dialog already sat 16px from each viewport edge.
Dialog.Contentpositions itself inside afixed inset-4wrapper, which nothing documented, so filling that box meant discoveringpreferredWidth="max-w-none"and addingh-fullby hand. Going edge to edge meant more: the wrapper takes no props, so the only way out wasfixed inset-0on 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.
preferredWidththerefore 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 computedappearancestill typechecks, so a consumer can toggle between appearances from state. "full-bleed"keeps its backdrop. The content opens atzoom-in-95, so a sliver of viewport edge stays uncovered for the length of the animation, and the overlay is what fills it.Dialog.Contentnow stampsdata-appearancewith the resolved value, so consumer CSS can target a full-bleed dialog without threading a class through.appearancedefaults to"centered", which paints exactly whatDialog.Contentpainted before, so no existing call site changes.
Dialogalso 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
- Every dialog already sat 16px from each viewport edge.
0.83.0
Minor Changes
-
#1380
c2f1cb0Thanks @cody-dot-js! - AddAvatar— 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.Avatar.Rootowns 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 issize-7andshrink-0, so the flex rows an avatar usually sits in cannot squeeze it;classNamemerges last, soclassName="size-10"wins.colorSeedhashes a stable id into one of 17 swatches and switches the foreground totext-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.Imageis 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.onErrorruns first and canpreventDefault()to keep the image mounted, and changingsrcis always a fresh attempt. Itsaltis required — stricter than the<img>element, because an avatar is the one image everyone forgets and an<img>with noaltannounces its URL. Passalt=""when adjacent text already names the subject, which is the common case.Avatar.Fallbacktakes eitherchildren(an icon, a monogram) orname, 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 arearia-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
asChildand stampsdata-slot(avatar,avatar-image,avatar-fallback), joining an ancestor-forwarded chain rather than replacing it.Avatar.Rootrenders a<span>, so it stays valid inside the<button>of a switcher row.
Sidebar.AccountAvatarandSidebar.UserAvatarare 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: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), anddata-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
2f1d03cThanks @cody-dot-js! - Command: a search trigger, a statefulDialogRoot, and the⌘Kshortcut.Command.SearchTriggeris 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 inCommand.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-keyshortcutsannounces the⌘Kbinding 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. Itsdata-slot="command-search-trigger"joins ahead of the child's own.Command.DialogRootis now a real state owner instead of an alias forDialog.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.Inputmounts 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 withuseCommandDialog().querywhen your palette does its own matching;Command.Inputstill acceptsvalueto take the query over completely. - The
⌘K/Ctrl+Kshortcut, new and on by default (keyboardShortcut). It claims the event (Firefox binds⌘Kto its own search bar), keeps the two platform modifiers distinct so macOS's nativeCtrl+Kstill works, has exactly one owner per window so two palettes never open on one keypress, and yields insidecontenteditablehosts and<textarea>s where⌘Kis already bound.
If you already bind
⌘Kyourself, passkeyboardShortcut={false}— otherwise the chord has two owners after this upgrade.Command.SearchTriggerthen adds noaria-keyshortcuts, anduseCommandDialog().keyboardShortcutreports the same flag so your row can drop its visible⌘Khint too — the UI never advertises a binding mantle does not own.Also new, for triggers that cannot use
Command.SearchTriggerat all:useCommandDialog()(open,setOpen,openWithQuery,toggle,query,setQuery,keyboardShortcut), and the pure helperssearchActionFromKeyDown/searchActionFromPaste, which classify a keystroke or paste into aSearchAction—seed,open, orignore. They handle the cases a hand-rolledevent.key.length === 1check gets wrong:Spaceseeding 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.DialogContentnow 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
- The open state, controlled (
-
#1341
6793a7cThanks @cody-dot-js! - Addinvert-theme— a first-class class that renders a DOM subtree in the opposite theme.Add the
invert-themeclass 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-themesubtrees 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
forceThemeanything other than"dark".forceThemenow 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(), anduseInitialHtmlThemePropsall acceptforceThemeand pin the class,data-applied-theme, and the pre-paint write to it. If you useforceThemeonMantleStyleSheets, pass the same value to those too — otherwise a user whose stored preference resolves to dark gets<html class="dark">on aforceTheme="light"page and it paints dark. Before this releaseforceTheme="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 andsetThemestill writes it, sodata-themeand theme switchers are unaffected. See Locking a page to one theme.MantleStyleSheetsnow applies theme pairs together: the active theme's pair partner also getsmedia="all", so inverted islands always have their opposite theme's CSS applied.forceThemerenders 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-themeselector with:not([data-applied-theme]), so[data-theme="dark"]no longer selects the dark block on a page whosedata-applied-themesays otherwise. This is what makes a forced page hold its theme whiledata-themekeeps the preference for the theme switcher.data-themeon 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.cssitself 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:rootand 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 aninvert-themeisland: 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-themeto the floating part'sclassName), 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
6793a7cThanks @cody-dot-js! - AddSandbar— a floating save bar for unsaved changes.Sandbaris 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, controlledopenprop),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-levelAlertthat 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 handleRefreceives aSandbarHandle—shake(options?: { announcement?: string })wiggles the panel (skipped underprefers-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, soasChildcomposition accumulates the whole chain instead of clobbering it. The panel also stampsdata-state="open" | "closed", which drives the enter/exit transition. No public CSS variables.
Import it:
import { Sandbar } from "@ngrok/mantle/sandbar". - Compound parts:
-
#1382
2f1d03cThanks @cody-dot-js! -Sidebar.SearchTrigger: the sidebar's search row.A new part for the top of
Sidebar.Body(orSidebar.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.ItemButtonandSidebar.SearchTriggernow 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
shortcutprop 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 isaria-hidden(thearia-keyshortcutsthatCommand.SearchTriggeradds 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.shortcutandasChildare 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.SearchTriggerfor the dialog wiring and the typing behavior, and withSidebar.Tooltipso the row keeps a visible label in the rail.Sidebar.Tooltipalso gained ashortcutprop, 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.Headeris a fixed height so it can align with anAppLayout.Headertoolbar, and that height is sized for one row. If you stack the switcher and the search row there, raise--sidebar-header-heighton a common ancestor of both rows —<AppLayout.Root className="[--sidebar-header-height:6rem]">. Setting it onSidebar.Navonly looks right: custom properties inherit downward, soAppLayout.Headerwould keep the4.5remdefault and the two rows would stop being center-aligned.Docs: https://mantle.ngrok.com/components/navigation/sidebar#sidebarsearchtrigger
-
#1382
2f1d03cThanks @cody-dot-js! -Sidebar.Triggergets a tooltip and announces the shortcut it binds.Sidebar.Roothas bound⌘B/Ctrl+Bsince the sidebar shipped, but the trigger told nobody: assistive technology got noaria-keyshortcuts, and pointer users got no label at all — the button is icon-only at every breakpoint, and its name wassr-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 showinglabel— plus the newshortcutprop, for the visible⌘Bchips (shortcut={<><MetaKey /><Kbd>B</Kbd></>}). UnlikeSidebar.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 samekeyboardShortcutflag so a custom trigger can be equally honest.Sidebar.Triggernow requires aTooltipProviderancestor, like anyTooltip.Root— and likeSidebar.Tooltipalready 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:
Patch Changes
-
#1389
793d82aThanks @cody-dot-js! - Keep a longBreadcrumbtrail 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.Listis now the scroll container, and its edges carry the samescroll-fade-xmask asTabs.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-paddingthe 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.Rootcarriesmin-w-0, so inside a flex row such asAppLayout.Headerthe landmark gives up width to the row instead of pushing its siblings out of it.Breadcrumb.ItemandBreadcrumb.Separatorno 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.Listpaints, its own background and border included, so keep that styling on a wrapper; and it owns the element'smask-image, so a second mask on the same element replaces it.To keep the old wrapping behavior, hand the scrollport back:
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
c117f9eThanks @cody-dot-js! - Every chart repaints: all eight series colors change, in all four themes.BarChart,LineChart,AreaChart, andScatterPlotall 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-1againstchart-2,chart-2againstchart-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
lightand indark, 1.55 inlight-high-contrast, and 1.71 indark-high-contrast. At 1.90,chart-1andchart-7are 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.
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, andlight-high-contrastkeep every slot inside their band.dark-high-contrastkeeps 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. Indark, the closest categorical slot to that gray falls from ΔE 4.86 to 3.81 under simulated CVD. The other three themes rise:lightto 6.24,light-high-contrastto 8.72, anddark-high-contrastto 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
lightto hue 43 indark-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 indarkand six indark-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-500no longer moveschart-1. Override the chart token itself instead.To retheme one built-in slot, redeclare the token on the chart's
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 thecolorprop: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
c117f9eThanks @cody-dot-js! - A series that sets noshapenow 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.PointShapegrows 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-shapeattribute 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 withseriesSlot="other"wears the circle. Several series may share one slot. If another channel must tell them apart, give each member its ownshapeortexture.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
shapestill wins. The paired glyph is a default, not a policy. A chart that already setsshapeon every series paints exactly as it did before.Bar series are unchanged. A bar encodes identity by
texturerather than by glyph, and texture stays opt-in — nothing assigns one for you.shapeserves the line, area, and scatter marks. -
#1390
c117f9eThanks @cody-dot-js! - AddseriesSlottoBarChart.Bar,LineChart.Line,AreaChart.Area, andScatterPlot.Pointfor charts that need a fixed visual identity.Series receive unreserved slots in composition order. Set
seriesSlotto give a series a fixed visual identity. Slots1through8select 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
colorandshapeprops 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, replacecolor="chart-4"withseriesSlot={4}. -
#1390
c117f9eThanks @cody-dot-js! - Bar Chart: two new fill textures, one per color slot.BarChart.Bar'stextureprop 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'sdata-textureattribute 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'sorientation, 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: anorientationchange 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
seriesSloton every member, then give each member its owntexture. The chart reserves that slot once. Three series withseriesSlot={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
seriesSlotneeds 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.
- Every member sets the same
-
#1387
69d6c7bThanks @cody-dot-js! - FixChoicepublishing none of its per-part documentation, so hoveringChoice.Rootin an editor showed nothing.Each
Choicepart carries a summary, a@seelink to its docs anchor, and an@examplein 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.distshipped a bare member list.Choicewas the only namespace in the package written that way, so every other compound component already documented its parts here.The annotation is gone.
as constalone emits the same named member types and keeps the documentation, and it restores thereadonlymodifiers the annotation was discarding:readonly Root: typeof Rootinstead ofRoot: typeof Root. The parts, their props, and their runtime behavior are unchanged.COMPONENT_SPEC.mdasks 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
0d9bf76Thanks @dependabot! - Bump the@tanstack/react-virtualruntime dependency to 3.14.9. -
#1378
4b74f6cThanks @cody-dot-js! - FixSidebar.Tooltippopping a label when the panel collapses, with no pointer anywhere near the row.The part gated its
Tooltip.Contenton the collapsed rail state but leftTooltip.Rootto 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 theSidebar.Trigger, so a tooltip appeared on nearly every collapse. The same stale open state pointed the row'saria-describedbyat an id that was not in the document and leftdata-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 noaria-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.
disableHoverableContentmoves 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.Rootstill stays mounted at every rail state, so toggling the rail never drops focus off the row the user was on. - the expanded panel and the mobile sheet keep every row at
-
#1386
9db190aThanks @cody-dot-js! - FixBarChartrendering 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.
roundedcame fromseriesIndex === specs.length - 1, computed once per series outside the per-column loop. A value of exactly0is not a gap — it stacks withlower === 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.computeStackBoundariesnow 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 at0, 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.
computeStackBoundariesstores a negative segment inverted, so readingloweras 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 purestackedSegmentEdgeshelper 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)pxwhile 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'sdataKeycame 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 at0— 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>-markersjoin 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
dataKeyon first registration. Series that mount together register in composition order, but a series that mounts later paints last whatever its position in the JSX. AdataKeythat 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.
- The hover band no longer washes over its neighbors. It drew at
-
#1385
6e679f6Thanks @cody-dot-js! - Rewrote comments and JSDoc across the library against the newCONVENTIONS.md § Writingstandard. 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.Valuenow names the case where the styling restriction bites: underSelect.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.Rootnames 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.Itempoints atonValueChangeinstead of the deprecatedonChange.Combobox.ItemValuesays thatvalueanduserValueoverride the defaults only when you pass them.TooltipProviderrestores the one-instance rule and names what a second provider actually changes: it applies its own timing props to its subtree, and it tracksskipDelayDurationseparately, so a pointer that moves between the two waits the delay again.useBreakpointstates what it returns — the largest matching Tailwind breakpoint, or"default"below2xs— rather than narratinguseSyncExternalStore.useIsBelowBreakpointstates what it returns:truewhile 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, andThemeare the visible ones, and each docs-page copy moved with them.Tabs.RootandCombobox.Rootstate 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.Contentcarriesdata-orientationthrough Radix's own context, andCombobox.ItemValueandCombobox.Groupread the ariakit store through ariakit.Tabs.Rootnow names thedata-orientationanddata-appearanceattributes it stamps, which any descendant can select against.Four summaries no longer read as a copy of a sibling's:
IconButtonleads with what makes it an icon button — one icon and nothing else, with the requiredlabelprop carrying the accessible name. It opened withButton's three sentences verbatim.Switchnames its difference fromCheckbox: no indeterminate state. The two shared one sentence.Icons,Pagination, andThemesay what their module exports. Each published only "Re-exports for the … component."Alert.Iconwas missing a verb, and its three copies disagreed three ways.
-
#1378
4b74f6cThanks @cody-dot-js! - Modernize theSidebarandAppLayoutclass strings to Tailwind v4 syntax. The compiled CSS is unchanged — these are the shorthands v4 added for what the bracket forms already expressed:The CSS variables, their fallbacks, and the selectors they compile to are identical — the variable shorthand carries its fallback (
w-(--sidebar-width,16rem)still emitswidth: var(--sidebar-width,16rem)), and both:has()forms match the samedata-slottoken. Only the class strings on the elements changed, so update any test or stylesheet that matches them literally.
0.82.1
Patch Changes
-
#1375
e57524aThanks @cody-dot-js! - Fix six defects and restructureAppLayout, from migrating the ngrok dashboard onto the app shell (#1374). Every one of these failed silently, which is what made them expensive.AppLayoutis restructured, and this is breaking. The card now reads like every other mantle surface —ContentwrappingHeader+Body, the same shape asSidebar.Nav,Dialog.Content,Sheet.Content, andCard.Root:Migration, in order:
AppLayout.Body→AppLayout.Workspace(the row holding the rail beside the card). Same classes, anddata-slot="app-layout-workspace".AppLayout.Insetis removed. Its child moves up to be a direct child ofAppLayout.Workspace; the card gutter it owned is nowAppLayout.Content's own margin, driven by the new public--app-layout-card-gutter(default0.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.AppLayout.Bodyis now the page region, and the shell's only scroll container. Render your<Outlet />here, and composeMainonto it viaasChildinstead of ontoAppLayout.Content—SkipToMainLinkfocuses#main, so the landmark has to be the scrollport or arrows,Space, andPageDowndo nothing after a skip.data-slot="app-layout-body"now names this element.AppLayout.Headerrenders a<div>, not a<header>, and is no longersticky. WithMainonAppLayout.Body, a<header>here would have no sectioning ancestor and would therefore become the ARIAbannerlandmark. Compose your own element withasChildif you need one.
There is deliberately no
Footerpart. 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 />insideAppLayout.Bodycould 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.Contentis now a containing block (relative). It was a scroll container but not positioned, so absolutely-positioned page content resolved againstAppLayout.Root—fixed inset-0in a real shell — and painted across the sidebar rail. -
A page can finally fill the card.
AppLayout.Headerwasstickybut in flow inside adisplay: blockscroll container, so a page usingh-fulloverflowed by exactly the toolbar's height (measured: 54px), leaving a bottom-pinned bar below the fold on every load. The toolbar is now ashrink-0sibling outside the scrollport, so it is pinned by construction, cannot be overlapped by a page's ownsticky top-0, and steals no height.AppLayout.Bodyis a flex item that is block inside, which is what letsh-fullfit the card whilemx-auto max-w-7xlstill centers — as a flex child, auto margins beatalign-items: stretchand that container would shrink-to-fit, getting narrower as the viewport got wider. -
AppLayout.Contentisoverflow-clip, notoverflow-hidden.hiddenis 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. -
Slotcomposes a render-propclassNameorstyleinstead of dropping it. A functionclassNameresolved to""throughcxand a functionstylewas overwritten by prop merging, so a react-routerNavLinkcomposed through anasChildmantle part silently lostclassName={({ isActive }) => …}— the row's highlight just never turned on.Slotnow 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 needscurrentderived fromuseMatchfor accessibility (NavLinksetsaria-current="page"itself).Slotnever calls the function, so it needs to know nothing about the argument. Parts that forwardasChildstraight to a Radix primitive (Tooltip.Trigger,DropdownMenu.Trigger,Dialog.Trigger) still use Radix's own slot, which joins a functionclassNameinto the class string — nest a mantle-backed part inside those. -
Sidebar.ItemButtonstyles its current row fromaria-current="page"as well asdata-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 nocurrent, nouseMatch, and noclassName. Before this, aNavLinkwas announced as the current page but looked like every other row unless the parent re-derived the route to passcurrent, or hand-rolled the active styling in app code.currentis unchanged and still sets both attributes; keep using it for rows whose parent knows (a plainLink, a custom matcher). -
extractSidebarStateCookieno longer throws on a malformed cookie. Its documented contract already said it answersundefinedfor an unparseable value, but the rawdecodeURIComponentcall raisedURIErroron a bad percent-escape — and aCookieheader 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 byextractSidebarStateCookie,extractThemeCookie, andgetStoredTheme, which also fixes a latent truncation bug: the theme readers split on=and so would cut a value at its first=. -
AppLayout.Rootclips instead of hiding.overflow-hiddenis a scroll container that paints no scrollbar — the same trapAppLayout.Contentwas 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 withfixed inset-0that is the whole window, with no scrollbar and no gesture to bring it back.AppLayout.Rootis nowoverflow-clip, which is not a scroll container at all. -
Two
AlertCenter.Items sharing anidnow 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.AccountAvatarandSidebar.UserAvataracceptasChild. 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; thesrcimage or silhouette) renders inside your element. -
Sidebar.TooltiptakesTooltip.Content's props. It was a closed{ children, label }object, so the tooltip surface it renders was unreachable andside="right"was hardcoded.className,style,ref,data-*, positioning props such asalignandsideOffset, andasChildnow land on the tooltip;sideis a prop defaulting to"right".childrenstays a required single element. -
New
data-slothooks:alert-center-bar-wrapper(the presence wrapper that ownsdata-state),alert-center-dismiss-icon-button, andalert-center-announcer(therole="status"live region).AlertCenter.Bar,AlertCenter.Content, andAlertCenter.DismissIconButtonnow join an incomingdata-slotchain rather than replacing it — note the dismiss button's own slot value replaces the inheritedalert-dismiss-icon-button;Alert'sdata-alert-dismissattribute is unchanged. -
Sidebar.Separator,Sidebar.AccountAvatarandSidebar.UserAvatarjoin an incomingdata-slotinstead of being clobbered by it. All three stamped their slot name before spreading rest props, so a consumer-passed or ancestor-forwardeddata-slotreplacedsidebar-separator/sidebar-account-avatar/sidebar-user-avataroutright 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+Brespects the platform and text fields. The sidebar shortcut accepted either modifier on every platform, so on macOSCtrl+B— the native emacs-style "move the caret back one character" that every macOS text field binds — waspreventDefaulted 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 anycontenteditablehost.
Additive:
Sidebar.Tooltiplabels 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 itsTooltip.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 aTooltipProviderancestor, like anyTooltip.Root— it deliberately does not mount its own, so app-wide delay settings still apply.extractSidebarStateCookie/serializeSidebarStateCookie/SIDEBAR_STATE_COOKIE_NAMEfrom@ngrok/mantle/sidebar— the SSR-safe pair for persisting the collapsed state, mirroringextractThemeCookie. 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 withdefaultOpen, not controlledopen.extractSidebarStateCookiereturnsboolean | undefinedso 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.Rootabove the shell for route-authored alerts, and testing guidance for the alert center's intentionally duplicated title text.
0.82.0
Minor Changes
-
#1338
3bd61efThanks @cody-dot-js! - Add theAlertCentercompound 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: eachAlertCenter.Itemcomposes the normalAlertbanner parts as children (real anchors, arbitrary content) and registers its coordination facts (id,intent) with the renderlessAlertCenter.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.moveBeforewhere 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'smainlandmark 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.Baris 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]:hiddenkeeps any element out of the bar) — plus a+N moretrigger, collapses to nothing when empty (likeAppLayout.Notice), redirects keyboard focus to the next control when a focused top alert is dismissed, and claims no ARIAbannerlandmark (arrivals and re-ranks are announced by a persistent, visually-hiddenrole="status"live region thatAlertCenter.Rootmounts).AlertCenter.Contentexpands the remaining items inline as ranked, full-width banner rows.AlertCenter.DismissIconButtonis 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 withdata-placement="bar" | "list"anddata-alert-idas documented styling hooks. Also exports theAlertCenterIntent,AlertCenterItemProps, andAlertCenterRootPropstypes. Docs: https://mantle.ngrok.com/components/feedback/alert-center -
#1338
3bd61efThanks @cody-dot-js! - Add theAppLayoutcompound component (@ngrok/mantle/app-layout): a viewport-locked application shell that never scrolls itself.AppLayout.Rootfills its nearest sized ancestor (mergeclassName="fixed inset-0"to pin a real app shell to the viewport);AppLayout.Noticeis a full-window strip above everything for maintenance banners and environment warnings;AppLayout.Bodyis the flex row where aSidebar.Navcomposes besideAppLayout.Inset;AppLayout.Contentis the roundedbg-cardcard that is the shell's only scroll container (overflow-y-auto overscroll-none) — compose theMainlandmark into it viaasChild; andAppLayout.Headeris the sticky toolbar<header>rendered asContent's first child (aSidebar.Triggerand breadcrumbs live there; it ish-14standalone, and when the shell contains aSidebar.Headerit derives its height from the sidebar's--sidebar-header-heighttoken so the two rows stay center-aligned by construction). The shell is otherwise deliberately unaware of any sidebar. All parts supportasChildand accept arefprop. Docs: https://mantle.ngrok.com/layouts/app-layout -
#1338
3bd61efThanks @cody-dot-js! - Add theSidebarcompound component (@ngrok/mantle/sidebar): a composable, collapsible app-navigation sidebar.Sidebar.Rootowns the state and renders no DOM (controlled/uncontrolledopen+onOpenChange, controlled/uncontrolledopenMobile+onOpenMobileChange, and a configurablemobileBreakpoint, defaultlg);Sidebar.Navrenders 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-sideSheetbelow the breakpoint;Sidebar.Triggeris anIconButtonwitharia-expanded/aria-controlsthat toggles whichever presentation is active from anywhere under the root;⌘B/Ctrl+Balso toggles it (exact-modifier match; single-owner — with multiple mounted roots only the first claimant handles the keypress; opt out withkeyboardShortcut={false}). Navigation composesSidebar.Header/Body/Footer,Sidebar.Group/GroupLabel/List/Item/ItemButton(withcurrent→aria-current="page", group labels wired to lists viaaria-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), plusSidebar.SwitcherTrigger,Sidebar.Separator,Sidebar.AccountAvatar(deterministic, WCAG 4.5:1-contrast swatches), andSidebar.UserAvatar.Sidebar.Bodyfades its overflowing edges with thescroll-fade-ymask (and hides its scrollbar inside the collapsed icon rail). Also exports theuseSidebarhook and theSidebarStateandSidebarMobileBreakpointtypes. Public CSS variables:--sidebar-width(default16rem),--sidebar-width-mobile(default18rem), and--sidebar-width-icon(default3.25rem) — read with those defaults as fallbacks, so set them onSidebar.Navfor one sidebar or on any ancestor (:root) to resize every sidebar in an app — plus--sidebar-header-height(default4.5rem), the header row height thatAppLayout.Headeralso derives from so the two rows stay center-aligned — set it on a common ancestor of both, and--sidebar-row-width, whichmantle.cssderives at:rootascalc(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
0a17568Thanks @cody-dot-js! - Correct JSDoc across Calendar, Progress Bar, Progress Donut, Radio Group, and Scatter Plot (component audit sweep). No API changes.ProgressBarandProgressDonut: the@seelinks onRoot,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#progressdonutindicatorAPI-reference anchors.Calendar: dropped the stale#api-calendarfragment so the@seelink points at the component's docs page.ScatterPlot: the Composition tree omittedScatterPlot.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
bf7fc75Thanks @dependabot! - Bump runtime Radix UI dependencies:react-dialog1.1.21 → 1.1.23,react-dropdown-menu2.1.22 → 2.1.24,react-hover-card1.1.21 → 1.1.23,react-popover1.1.21 → 1.1.23,react-progress1.1.14 → 1.1.16,react-select2.3.5 → 2.3.7,react-slider1.4.5 → 1.4.7,react-slot1.3.1 → 1.3.3,react-switch1.3.5 → 1.3.7,react-tabs1.1.19 → 1.1.21, andreact-tooltip1.2.14 → 1.2.16.Bump
@ariakit/react0.4.34 → 0.4.35, which fixes native form submission forMultiSelect. Aname— passed toMultiSelect.Inputdirectly or flowed fromField.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 thenameattribute off the rendered combobox input (or queried it by name) must target the hidden inputs instead; consumers reading selection throughselectedValue/setSelectedValueare unaffected. -
#1338
3bd61efThanks @cody-dot-js! -Mainnow joins an incomingdata-slotchain ahead of its own"main"slot name (previously the chain was clobbered), so it composes cleanly as anasChildchild of layout parts (e.g.<AppLayout.Content asChild><Main>…</Main></AppLayout.Content>). -
#1368
f591fd0Thanks @forzalupo! - RenderBreadcrumb.Pagewith 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
672479aThanks @cody-dot-js! - Refine Accordion JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Remove the ReactdisplayNameassignments from Alert's sub-components; the displayName convention has been dropped. -
#1356
672479aThanks @cody-dot-js! - Refine Area Chart JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Bar Chart JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Replace a ternary used insidecx()for Calendar's day class names with convention-compliant class composition (no behavior change). -
#1356
672479aThanks @cody-dot-js! - Refine Checkbox JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Code Block JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Command JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Description List JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Dropdown Menu JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Field JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Flag JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Icons JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Line Chart JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Media Object JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Pagination JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees.Also drop the
asChildprop fromCursorPagination.PageSizeValue. It never worked —PageSizeValuehard-codes its{pageSize} per pagetext as children, which overrode the composed child and madeSlotthrowReact.Children.only. It now always renders aspan. -
#1356
672479aThanks @cody-dot-js! - Refine Radio Group JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Sandboxed On Click JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Sheet JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Split Button JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Table JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Export theTextAreaPropstype from@ngrok/mantle/text-area(itsindex.tsbarrel previously dropped the type re-export). -
#1356
672479aThanks @cody-dot-js! - Refine Theme JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1356
672479aThanks @cody-dot-js! - Refine Toast JSDoc and documentation (component audit): correct@seelinks, complete examples, and align Composition trees. No API changes. -
#1350
635dbb3Thanks @cody-dot-js! - Add composable trailing expand and dismiss controls to Alert. -
#1353
3d92c6dThanks @cody-dot-js! - Add adecorativeprop to every chart Root —BarChart,LineChart,AreaChart, andScatterPlot— 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
inertplus 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
decorativeare modeled as a discriminated union, so invalid combinations are unrepresentable: an interactive chart (the default) still requires exactly one ofaria-label/aria-labelledbyand 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.decorativecommunicates intent ("these values are not information") where adisabledflag would be ambiguous, since a chart is not a form control. -
#1355
2684706Thanks @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 at0, 1, 2, 3instead of0, 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, andtickCountremains an approximate hint. -
#1345
685e5f5Thanks @dependabot! - Bump runtime dependencies:@tanstack/react-virtualto 3.14.8 and the Radix UI primitives to their latest patch releases (react-dialog1.1.21,react-dropdown-menu2.1.22,react-hover-card1.1.21,react-popover1.1.21,react-progress1.1.14,react-select2.3.5,react-slider1.4.5,react-slot1.3.1,react-switch1.3.5,react-tabs1.1.19,react-tooltip1.2.14).
0.81.0
Minor Changes
-
#1340
f5b991eThanks @cody-dot-js! - Add the canvas chart family:BarChart(@ngrok/mantle/bar-chart),LineChart(@ngrok/mantle/line-chart),AreaChart(@ngrok/mantle/area-chart), andScatterPlot(@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-motionsnaps everything.ScatterPlotcorrelates two continuous measures — or three: providingzKeyrenders 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. Adimensionsprop (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-8and--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 likebg-chart-1). Color slots are sticky perdataKey, so filtering series never recolors the survivors. - Interaction ships with
Rootunconditionally: 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 controlledactiveIndexfor 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 indata-value, so agents scrape precise series data from the DOM. A composableCopyButtonpart 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") onScatterPlot.Point(the marks themselves),LineChart.Line(canvas markers), andAreaChart.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") onBarChart.Rootnames 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") onBarChart.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-labeloraria-labelledby). pendingholds the previous render at reduced opacity during refetch — no skeleton flash.
- New design tokens
Patch Changes
-
#1347
ec09b1dThanks @cody-dot-js! - Decouple cross-component helper imports so importing one component no longer pulls another component's implementation into the bundle:preventCloseOnPromptInteractionmoved out oftoast.tsxinto 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%).iconButtonVariantsmoved out oficon-button.tsxinto its own module, soCalendar(which only needs the classes for its nav buttons) no longer imports theIconButtonimplementation.
No public API changes — both helpers were internal.
-
#1346
4dbed4eThanks @cody-dot-js! - Remove allComponent.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
959ee6dThanks @cody-dot-js! - BREAKING: Drop React 18 support —@ngrok/mantlenow requiresreact@^19andreact-dom@^19peers (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*Propstype includesrefviaComponentProps. composeRefsanduseComposedRefsnow 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 legacynullwrite on detach).- Removed redundant
displayNameassignments — component names are inferred from their declarations; intentional DevTools names on compound parts are kept.
- All components use React 19's native ref-as-prop instead of
Patch Changes
-
#1334
57cfb4dThanks @cody-dot-js! - AddCenteredLayout.Notice: a full-window-width strip pinned above everything else in the layout — includingCenteredLayout.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, mergesticky top-0 z-20viaclassNameto keep it pinned to the window. -
#1337
758355fThanks @forzalupo! - Use a neutral action button for informational alert dialogs.
0.79.3
Patch Changes
-
#1328
f5c6bfdThanks @dependabot! - Bump the@ariakit/reactruntime dependency from 0.4.32 to 0.4.34 and move thetailwindcsspeer range floor from^4.3.2to^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.Contentlocks page scroll when opened outside a mantle modal: in browsers withscrollbar-guttersupport, ariakit now reserves the gutter on thehtmlelement instead of hidingbodyoverflow with padding compensation, and its--scrollbar-widthCSS 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 setscrollbar-gutteroroverflow-y: scrollonhtmlno longer shift when the popover opens. The bump also picks up a Korean IME fix forautoSelectcomboboxes and faster component mounts. Mantle's in-modal scroll-lock opt-out (preventBodyScrolldefaulting tofalseinside mantle modals) is unchanged and still required.Tailwind 4.3.3 is a bug-fix-only patch (notably, achromatic
oklchtheme 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
d8f5690Thanks @cody-dot-js! - Restore the classicTabs.Listborder, drawn by default in theseparatorcolor token: horizontal tablists draw a 1px bottom border and vertical tablists draw the matching side border (also theseparatortoken now, previouslygray-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
hideBorderprop onTabs.Listto remove the border; it is also rendered as adata-hide-borderattribute on the tablist element. The pill appearance never draws a border, sohideBorderhas no effect there.Tabs.Rootnow renders adata-appearanceattribute for appearance-scoped styling.If you previously worked around the missing border by adding your own
border-btoTabs.List, remove it — otherwise you'll render a doubled, two-tone border.
0.79.2
Patch Changes
- #1324
3a57903Thanks @forzalupo! - Refine CodeBlock backgrounds, hover states, and header typography.
0.79.1
Patch Changes
-
#1322
33d6214Thanks @cody-dot-js! - Breaking:ThemeSwitcheris now a compound component — composeThemeSwitcher.Root,ThemeSwitcher.Trigger, andThemeSwitcher.Contentinstead of rendering a single<ThemeSwitcher />. ThecontentPropsprop bag is gone: passDropdownMenu.Contentprops (align,side,collisionPadding,className, …) directly toThemeSwitcher.Content, andIconButtonprops (appearance,label,className, …) directly toThemeSwitcher.Trigger.ThemeSwitcher.ContentrendersThemeDropdownMenuRadioGroupwhen no children are given; pass children to own the menu contents.ThemeSwitcherPropsis replaced byThemeSwitcherRootProps,ThemeSwitcherTriggerProps, andThemeSwitcherContentProps;ThemeDropdownMenuRadioGroupis unchanged. The trigger'sdata-slotis nowtheme-switcher-trigger(wastheme-switcher) and the popover's istheme-switcher-content dropdown-menu-content—DropdownMenu.Contentnow joins an incomingdata-slotchain ahead of its own slot name instead of being clobbered by it, so[data-slot~="dropdown-menu-content"]selectors keep matching wrapped menus.Migration:
0.79.0
Minor Changes
-
#1316
efbf621Thanks @cody-dot-js! - Breaking:AlertDialog.Root'spriorityprop is renamed tointent(still required; the value union is unchanged:info | danger).AlertDialog.Actionstill derives its button tone from the dialog's intent —danger→ a danger button, otherwise an accent button (the old "default") — andAlertDialog.Action/AlertDialog.Cancelkeepappearanceandintentoptional (their wrapper defaults are the design; a consumer-passed value still wins). Call sites that passedprioritytoActionrename it tointent. See the migration guide at https://mantle.ngrok.com/migrations/priority-to-intent-migration. -
#1316
efbf621Thanks @cody-dot-js! - Breaking:Alert.Root'spriorityprop is renamed tointent(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
efbf621Thanks @cody-dot-js! - Breaking:ButtonandIconButtonreplaceprioritywithintent, and bothappearanceandintentare now required props with no defaults — every call site states its visual weight and tone explicitly.Button's tone union isaccent | danger | neutral(accentreplaces the olddefaultvalue;dangerandneutralare unchanged).IconButtongains theintentaxis (accent | danger | neutral) with ghost/outlined tone styling mirroringButton; its pre-intent rendering was neutral-toned, sointent="neutral"is the visual-parity value for existing icon buttons. Buttons emitdata-intentinstead ofdata-priority. New type exports from@ngrok/mantle/button:ButtonIntent,ButtonAppearance, andIconButtonAppearance. 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 toappearance="outlined"+intent="accent"on Button andappearance="outlined"+intent="neutral"on IconButton; a filled Button that omittedprioritybecomesintent="neutral", the system's default primary action, instead of the accent tone it used to inherit). -
#1316
efbf621Thanks @cody-dot-js! - Breaking (pass-through surfaces):SplitButton.PrimaryActionnow omitsintentinstead ofpriorityfrom its forwarded Button props (it still renders outlined + neutral; it never accepted the tone prop).DataTable.HeaderSortButtonandDataTable.RowExpandButtonforward the renamed props: call sites that passedprioritytoHeaderSortButtonrename it tointent(appearance/intentstay 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
efbf621Thanks @cody-dot-js! - Breaking:Toast.Root'spriorityprop is renamed tointent(still required; the value union is unchanged:danger | info | success | warning), and the public type exported from@ngrok/mantle/toastis renamed fromPrioritytoToastIntent. See the migration guide at https://mantle.ngrok.com/migrations/priority-to-intent-migration.
0.78.0
Minor Changes
-
#1313
2f5ddc9Thanks @cody-dot-js! - AddBreadcrumb, hierarchy-path navigation at@ngrok/mantle/breadcrumbfollowing the WAI-ARIA APG breadcrumb pattern: six compound parts —Breadcrumb.Root(a<nav>landmark labeled"Breadcrumb"by default, overridable viaaria-labelfor 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'sLinkviaasChild),Breadcrumb.Page(a<span>witharia-current="page"marking the current, non-link crumb), andBreadcrumb.Separator(arole="presentation"+aria-hiddendivider defaulting to a caret icon, replaceable with custom children like a slash). Every part supportsasChild. Documented at https://mantle.ngrok.com/components/navigation/breadcrumb. Purely additive — no changes to existing components. -
#1313
2f5ddc9Thanks @cody-dot-js! - AddCenteredLayout— mantle's first published layout primitive, at@ngrok/mantle/centered-layoutand 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(themin-h-fullflex frame),CenteredLayout.Header(an optional semantic<header>utility strip for account chips and close buttons — mergesticky top-0 z-10viaclassNameto 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 noLogoorContentpart because they would own nothing), andCenteredLayout.Footer(a pinned semantic<footer>utility strip outside the centered region). Landmark semantics stay compositional: when the layout owns the document, compose mantle'sMaindirectly inBodyand pair aSkipToMainLinkas the first child ofRoot. 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
2f5ddc9Thanks @cody-dot-js! - AddThemeSwitcherandThemeDropdownMenuRadioGroupat@ngrok/mantle/theme-switcher— the canonical picker UI over mantle's theme system (ThemeProvider+useTheme).ThemeSwitcheris a compact standalone picker: a ghostIconButtontrigger (overridableappearanceand accessiblelabelprops) with an SSR-safe icon — aSkeletonuntil hydration, thenAutoThemeIconmatching the applied theme — that opens aDropdownMenuof the five themes.ThemeDropdownMenuRadioGroupis the five-themeDropdownMenu.RadioGroupit hosts, embeddable in any existing menu (e.g. an account-menuThemesubmenu) so long as it renders inside aDropdownMenu.ContentorDropdownMenu.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
2f5ddc9Thanks @cody-dot-js! - Docs-only: correctBreadcrumb.Separator's JSDoc to describe the actual default children — the size-3.5Icon-wrapped caret — after the sizing rework. -
#1315
f9445f5Thanks @cody-dot-js! - Add a unifiedsizeprop to the button family:Buttongainssize(xs | sm | md | lg | xl, defaultmd) andIconButton's existing scale grows to match (lgandxljoinxs | sm | md). The scale is shared — the same size name always renders the same box height on both components (xs24px,sm28px,md36px,lg40px,xl48px) — and the newButtonSizetype is exported from@ngrok/mantle/button.SplitButton.Rootalso acceptssizeand drives both of its halves through context, so the primary action and menu trigger always share one height;SplitButton.PrimaryActiondeliberately does not acceptsize.Buttonsizehas no effect whenappearance="link"(link buttons inherit surrounding typography and have no box to size) anddata-sizeis not emitted for link buttons.IconButtonnow always emitsdata-size(previously omitted whensizewas not passed);Buttonemitsdata-sizetoo, andSplitButton.Root's wrapper div gains adata-sizeattribute. Only the box scales — typography and icon glyph size are the same at every size. Purely additive: the defaultmdrenders exactly whatButtonrendered before. Seedecisions/2026-07-13-button-size-and-intent-api.md. -
#1288
9d988f2Thanks @dependabot! - Bump runtime dependencies:@ariakit/reactto 0.4.32,@tanstack/react-virtualto 3.14.6, and the Radix UI primitives to their latest patch releases (react-dialog1.1.19,react-dropdown-menu2.1.20,react-hover-card1.1.19,react-popover1.1.19,react-progress1.1.12,react-select2.3.3,react-slider1.4.3,react-switch1.3.3,react-tabs1.1.17,react-tooltip1.2.12). Thetailwindcsspeer range floor moves from ^4.3.1 to ^4.3.2. -
#1310
21c8e35Thanks @cody-dot-js! - Update every JSDoc@seelink to the reorganized docs URLs. Component docs now live atmantle.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@seelinks that pointed at pages that never existed (/components/progress,/components/theme-provider,/components/button-group,/components/calendar). No runtime changes. -
#1288
9d988f2Thanks @dependabot! -MultiSelect.Contentno 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/react0.4.30, the redundant lock snapshotted the body's inline style (including the modal's transientpointer-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 thepreventBodyScrollprop. -
#1310
21c8e35Thanks @cody-dot-js! - Fix incorrect RadioGroup JSDoc@exampleblocks: the change handler isonChange(Headless UI), notonValueChange; the namespace object is not renderable (<RadioGroup.Root>, not<RadioGroup>); andRadioGroup.ButtonGrouprenders the radio group itself, so the examples no longer nest it insideRadioGroup.Root— that composition binds the buttons to an inner, uncontrolled group and silently ignores outervalue/onChangeprops. Documentation only, no runtime changes. -
#1313
2f5ddc9Thanks @cody-dot-js! -Slotnow concatenatesdata-slotvalues 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 incomingdata-slotprop with their own slot name (Breadcrumb and CenteredLayout do, more to follow) accumulate the full chain through arbitrarily deepasChildcomposition.
0.77.0
Minor Changes
-
#1305
d9fe81fThanks @cody-dot-js! - feat(mantle): adduseDebounce,useLocalStorage, anduseSessionStoragehooksuseDebounce(value, { waitMs })returns a debounced copy of a value that only updates after the value stops changing forwaitMsmilliseconds. The sibling ofuseDebouncedCallbackfor 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 nativestorageevent, and corrupt entries resolve to the default instead of throwing.useSessionStorage(key, defaultValue)is the per-tab sibling ofuseLocalStoragewith the same contract, backed by sessionStorage.
-
#1291
aaa0247Thanks @cody-dot-js! - feat(mantle): addChoicelayout primitiveChoiceis 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 aCheckboxorRadioGroupoption with a rich, titled label, or reach for it directly for your own rows.- Pure layout + association:
Choice.Rootowns the control'sidand the description'sidand wires them (htmlFor,aria-describedby) without threading ids. Drop your own control intoChoice.Indicator— it is cloned with the sharedid/name/aria-*/disabled. Choice.Labelrenders a real<label htmlFor>(use when theChoiceowns the labeling);Choice.Titlerenders label-less text (use when an ancestor — a clickable row, a Headless radio item, or aField.Label— owns it). MirrorsField.Label/Field.LabelText.- Field interop: inside a
Field.Control, the field'sid/name/aria-*flow onto the control and the field'saria-describedbyis merged (never duplicated) with theChoice's own description. Outside aFieldit uses its own generated ids — the two are decoupled; neither requires the other.
- Pure layout + association:
-
#1303
65eb57bThanks @cody-dot-js! - feat(mantle): addListcomponentListis a scrollable list of clickable items — the action / navigation counterpart toSelectableList. ComposeList.Items inside aList.Root; each is a<button>(onClick) by default or your own element viaasChild(e.g. an<a>for navigation), with an optionalcurrentaccent. Suits account switchers, SSO provider pickers.- A non-selecting semantic list:
role="list"ofrole="listitem"items, styled like theMultiSelectpopover (bordered, roundedbg-popover; items highlight on hover / selection with an inset, rounded pill). - Non-virtualized by default. Swap
Root→VirtualRoot(sameItemchildren) to window long lists.@tanstack/react-virtualships with both list entrypoints; it is small — a few kB gzipped — and does no windowing work until aVirtualRootrenders. ItemsupportsasChildfor link/navigation items,current, anddisabled.currentsetsaria-current, so the current item is announced, not just tinted. A disabledasChilditem carriesaria-disabled, is removed from the tab order, and swallows clicks/activation — though consumers should prefer omitting thehreffor a genuinely disabled link.- Built on a shared internal list primitive (the scroll + item chrome and ARIA
wiring behind both
ListandSelectableList); the primitive itself is deliberately not exported. - Keyboard. Items keep their native tab order, and
Arrow↑/Arrow↓ /Home/Endalso 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 aVirtualRoot'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).
- A non-selecting semantic list:
-
#1303
65eb57bThanks @cody-dot-js! - feat(mantle): addSelectableListcomponentSelectableListis a filterable, multi-select grid of checkbox rows — the inline (non-popover) counterpart toMultiSelect/Combobox. Map your data intooptionsonce; the list owns filtering and selection. Each row is an APG gridrole="row"(aria-selected) whose cells hold a realCheckboxand aChoice-laid-out title + description — the pattern that lets a selectable row carry a real control and its own interactive content.- Grid, not a checkbox-group.
role="grid"/role="row"/role="gridcell"witharia-selected, a realCheckboxin the selection cell, and the title as aChoice.Labelnaming 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 viaquery/onQueryChangeonRoot; passfilterfor a custom predicate ((option, query) => boolean— the default is a trimmed, case-insensitive substring match over each option's plain text:labelText, or a stringlabel). Alabelmay be any ReactNode; a rich label requireslabelText(enforced by the option type) so the filter has text to match. - Disabled rows.
options[].disabledis the single source of truth —SelectableList.Itemhas nodisabledprop. Disabled rows render dimmed, carryaria-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-virtualships with every list entrypoint; it is small — a few kB gzipped — and does no windowing work until aVirtualViewportrenders. Windowed rows carryaria-rowindexwitharia-rowcounton the grid, keeping screen-reader counts true. SelectableList.Emptyis an always-mounted politerole="status"live region (visually hidden while options match), so an emptied filter is announced rather than the grid silently vanishing.SelectableList.SelectAllreusesselectAllCheckedto drive a tri-state header over the filtered options.- Pure helpers
filterSelectableOptions,optionLabelText,toggleSelectionValue, andsummarizeSelectionare exported for custom filtering/selection logic. - Built on a shared internal
listprimitive (scroll + row chrome and ARIA behind both list components). - Keyboard. The grid is a single tab stop with
aria-activedescendantnavigation:Arrow/Home/Endmove the active row andSpace/Entertoggles it. Because focus stays on the collection (never on a row), it works across the full set underVirtualViewporttoo — arrows scroll off-screen rows into view rather than stopping at the mounted window.
- Grid, not a checkbox-group.
Patch Changes
-
#1306
2e589b4Thanks @cody-dot-js! - fix: publish a lean package.json — prune custom export conditions and slim the manifestThe
@ngrok/src-live-typescustom export condition only exists so workspace apps can resolve live source files during local development, but it was shipping in the publishedpackage.json#exportsand pointing npm consumers at./src/...paths that are excluded from the tarball. A newprepack/postpacklifecycle pair now rewrites the manifest while the tarball is created and restores the pristinepackage.jsonafterwards:- strips all custom (
@-namespaced) export conditions - collapses
{ types, import }export entries to plain string targets (TypeScript resolves the sibling.d.tsautomatically) - drops
importconditions that duplicatedefault(the CSS entries) - removes
devDependenciesandscripts, 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. - strips all custom (
-
#1304
5bd39edThanks @cody-dot-js! - fix(mantle): scrollCodeBlock.TabListon overflow instead of wrappingCodeBlock.TabListnow scrolls horizontally with an edge fade (scroll-fade-xplusoverflow-x-auto) when its tabs exceed the header width, rather than wrapping the tab strip onto a second row.CodeBlock.TabTriggergainsshrink-0 whitespace-nowrapso 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 onclassNamecan drop the workaround. -
#1305
d9fe81fThanks @cody-dot-js! - fix(mantle): hooks bug fixes, JSDoc gaps, anduseRandomStableIdremovaluseUndoRedo: callingundo/redo/pushmultiple times within a single event handler now works correctly. Previously the callbacks read the previous render's stacks, so back-to-backundocalls returned the same snapshot twice and corrupted the history.- Remove
useRandomStableId. It had no consumers and React's built-inuseIdis the right tool; the hook also produced different ids on the server and client, making it a hydration-mismatch hazard. composeRefs/useComposedRefs: drop the deprecatedMutableRefObjecttype and internal type assertions; document both with proper JSDoc.- Add test coverage for
useCallbackRef,useIsomorphicLayoutEffect,useMatchesMediaQuery,usePrefersReducedMotion/getPrefersReducedMotion,useScrollBehavior,useUndoRedo, anduseComposedRefs.
-
#1305
d9fe81fThanks @cody-dot-js! - refactor(mantle): hoistuseIsHydratedsnapshot functions to named module-level functions and clarify JSDoc; add unit tests covering client and server rendering
0.76.11
Patch Changes
-
#1284
e6c3737Thanks @cody-dot-js! - fix(mantle): raiseSheettoz-50so it can stack overDialogSheet's overlay and content were pinned atz-40whileDialog(andAlertDialog, and every popover-family primitive) live atz-50. That fixed ordering meant aDialogalways painted over aSheetregardless of open order — opening aSheetfrom inside aDialogrendered theSheetbehind it.Sheetnow matchesDialogatz-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 letsz-50popovers/dropdowns/selects layer correctly over aDialog.Note: stacking a
Sheetover aDialognow compounds both backdrops (each renders a full-screenbg-overlay backdrop-blur-xs), dimming the layer beneath.Component docs and source JSDoc now call out Mantle's shared
z-50floating layer acrossDialog,Sheet,AlertDialog,Popover,Tooltip,HoverCard,DropdownMenu,Select,Combobox, andMultiSelect. The JSDoc coverage includes each floating component's root export, compound component namespace export, and floating content export such as.ContentorDropdownMenu.SubContent.
0.76.10
Patch Changes
-
#1281
618f59aThanks @cody-dot-js! - fix(mantle): ease the Accordion trigger's padding in step with the open/close slideThe open-state
pb-2padding 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 asAccordion.Content's height, so it eases in lockstep with the slide.motion-reducedisables the transition. -
#1282
b8acd99Thanks @cody-dot-js! - fix(mantle): add aseparatorcolor token and use it for all dividersAdds a semantic
separatorcolor token (bg-separator/border-separator/text-separator, …) that resolves per theme to whatSeparatoralready rendered:gray-500/20(light),gray-600/20(dark), andblack(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 fromdivide-y divide-separatoronAccordion.Root(previously a per-itemborder-card-mutedborder withlast:border-b-0); they match the separator color and are drawn by the root.OtpInput.Separator— previouslytext-muted, nowtext-separator.
Consumers can use
border-separator/bg-separator/divide-separatorfor their own dividers instead of replicating the color.
0.76.9
Patch Changes
-
#1279
6f0e022Thanks @cody-dot-js! - fix(mantle): keep the Accordion trigger's focus ring from overlapping the bodyWhen an open section's trigger was focused, the
ring-4focus 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 onAccordion.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 symmetricpy-4rhythm.
0.76.8
Patch Changes
-
#1276
8e176e5Thanks @cody-dot-js! -Accordionupdates — a newAccordion.Bodypart and an optionaltype.Accordion.Contentis split into aContentviewport plus a newAccordion.Body, matching theContent+Bodycomposition ofDialogandSheet.Accordion.Contentis now the zero-padding region that animates open and closed; the section's bottom padding moves toAccordion.Body. Keeping padding off the animating element is what lets a closed section collapse fully — a paddedh-0border-box can't shrink below its own padding — and it gives you a dedicated element to restyle. The accordion no longer forcestext-smon its content or its trigger; both inherit the ambient text size now (set a text class onAccordion.BodyorAccordion.Triggerwhere you need one).-
Accordion.Contentno longer applies its ownpb-4/text-sm. Wrap a section's content inAccordion.Bodyto keep the default bottom padding:Override
Accordion.Body'sclassName(for examplepb-0orpx-6) to retune the padding;cxresolves last-wins over the defaults.
Accordion.Root'stypeprop is now optional and defaults to"multiple". Omittingtypeis the same astype="multiple": sections open independently and opening one never closes another (andvalue/defaultValue/onValueChangeare typed as string arrays). Settype="single"to opt into the accordion where opening a section auto-closes the previously open one. This is additive — existing call sites that passtypeare unchanged — and mirrors the recentButtonchange wheretypebecame optional with a safe default. -
0.76.7
Patch Changes
-
#1274
2788217Thanks @cody-dot-js! - Rebuild theAccordionand graduate it from preview to a stable, production-ready component. It was a thin@radix-ui/react-accordionwrapper; it is now a framework-free disclosure built on plain<div>and<button>elements whose collapsed content stays in the DOM withhidden="until-found", so the browser's find-in-page (⌘F) can discover and auto-expand matches inside closed sections (synced via thebeforematchevent). Accessibility mirrors a native<details>/<summary>: the item is arole="group"and the trigger a<button aria-expanded>. Supports"single"and"multiple"modes with controlled (value+onValueChange) and uncontrolled (defaultValue) usage.Accordion.TriggerIconaccepts ansvgprop to override the default caret.Breaking changes:
- Removed
Accordion.Heading. Compose a rich header as a plain flex row withAccordion.Triggerand any sibling controls (e.g. an "Add Rule" button) directly insideAccordion.Item— no heading wrapper and no absolute positioning. Accordion.Rootnow requirestype("single"|"multiple") and typesvalue/defaultValue/onValueChangeper mode (astringin single mode, astring[]in multiple mode). Thecollapsibleprop is gone — single mode is always collapsible (use""for "nothing open").
- Removed
0.76.6
Patch Changes
-
#1270
3145538Thanks @cody-dot-js! - Remove unused dependencies and config.- Drop the
browserslistdevDependency and config key — nothing in the current toolchain reads them. The build uses tsdown with an explicittarget, and Tailwind 4 lowers CSS via its bundled lightningcss with its own defaults. - Drop the unused
date-fnsdevDependencyandpeerDependency. Mantle's source never importsdate-fns; the calendar'sreact-day-pickerdeclaresdate-fnsas its own dependency, so consumers no longer need to install it just for Mantle.
Both packages remain available transitively where they are actually used.
- Drop the
-
#1272
006ad3aThanks @cody-dot-js! - Vendor thecxclass-merge engine and drop theclsx+tailwind-mergeruntime dependencies.cxis now powered by a vendored, byte-for-byte port ofclsx+tailwind-merge(adapted fromcnfast), living insrc/utils/cx/vendor. Mantle's previousextendTailwindMergeoverrides — theemspacing scale (w-em,p-em, …) and thetext-mono/text-size-inheritfont-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+extendTailwindMergeimplementation (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:
cxcan also be called as a tagged template —cx`px-2 px-4 ${active && "bg-blue-500"}`— which caches by call-site identity.
clsxandtailwind-mergeare no longer dependencies of@ngrok/mantle. (clsxremains available transitively viaclass-variance-authority.)Export
clsxfrom the@ngrok/mantle/cxsubpath alongsidecx. - Output is byte-identical to the previous
0.76.5
Patch Changes
-
#1263
3703172Thanks @cody-dot-js! - Add expandable-row support toDataTablefor inline, per-row detail panels — restoring the click-to-expand record inspection pattern that the migration offGenericTablelost.New API, all driven by native TanStack Table expansion state (no invented state):
DataTable.Rowgains an optionalrenderExpandedprop — 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 theFragment, thegetIsExpanded()conditional, or the extra<tr>. It is called lazily (only while open), so collapsed rows never build their panel.DataTable.Rowalso now setsdata-expandedas a styling hook.DataTable.RowExpandButton— an accessible+/−toggle (@phosphor-icons/reactPlus/Minus) for a leadingcolumnHelper.displaycolumn. Renders a real<button>that setsaria-expandedand (only while expanded)aria-controls, stops click propagation so it never fires a row-levelonClick, lets a consumeronClickveto the toggle viaevent.preventDefault(), and renders nothing whenrow.getCanExpand()isfalse.DataTable.ExpandHeader— a narrow<th>for the toggle column, mirroringDataTable.ActionHeader.DataTable.ExpandedRow— the sibling<tr>renderExpandedwraps content in, also exported for full control (customcolSpan, multiple panels, bespoke markup). Spans every visible column, carries theidthatRowExpandButtontargets viaaria-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 exposesdata-expanded-contentfor styling.expandedRowId(row)— the exported helper that derives the shared, stable DOM id used by the toggle'saria-controlsand the expanded row'sid.
Add
jsonCodeBlockValueandjsonToShikiHtmlto@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 aDataTable.ExpandedRowwith no build-time plugin and no server roundtrip. Multi-line objects and arrays are collapsible by default (foldable, on), reusing the samecomputeJsonFoldRanges+ fold runtime as every other JSONCodeBlock, so the fold markup and behavior are identical.
0.76.4
Patch Changes
-
#1257
7a32ee9Thanks @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 taggedmantle-code-line-openerso 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 theLabelprecedent. 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-0is now a direct-child[&>*:not([data-separator])]:shrink-0.flex-shrinkonly affects direct flex items, so this collapses an unbounded subtree walk to a single parent check with no rendered difference.
- CodeBlock fold gutter (
-
#1256
1a48962Thanks @cody-dot-js! -ButtonandIconButtonnow defaulttypeto"button"instead of requiring it.The
typeprop is relaxed from required to optional on both components; when omitted it renderstype="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 passestype, so nothing changes for them; omittingtypesimply stops being a compile error.Forms note: because the default is now
"button", aButton/IconButtonthat relies on native form submission must opt in withtype="submit".When
asChildis used,typecontinues to have no effect and is not forwarded to the child. -
#1254
b5b72bcThanks @cody-dot-js! - Stop publishing source maps. The.js.mapfiles were ~58% of the package's unpacked size (and compressed tarball) and embedded the full original source viasourcesContent, despitesrc/not being shipped. Dropping them more than halves the published package.This affects nothing consumers import at runtime. The typed
.d.tssurface (with its JSDoc and@exampleblocks) 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
f7be346Thanks @cody-dot-js! - Updatereact-day-pickerto10.0.1(from9.14.0) and bump thetailwindcsspeer dependency to^4.3.1. TheCalendarcomponent already used the v9+ API surface — no removed navigation/focus/event props, formatter or label aliases, or renamedclassNameskeys — so this is a transparent dependency bump with no public API changes. -
#1247
846f5bdThanks @cody-dot-js! - Fix two issues withCheckbox'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 keepscheckeda 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 nativeindeterminateDOM 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
selectAllCheckedto@ngrok/mantle/checkbox— a helper that resolves the tri-statecheckedvalue (true/"indeterminate"/false) for a "select all" checkbox from{ allSelected, someSelected }selection counts, ready to pass straight toCheckbox. Encapsulates the correctness-prone tri-state branch every multi-select list reinvents. Also exports theCheckedStatetype. - A controlled checkbox toggling through
-
#1247
846f5bdThanks @cody-dot-js! - Update theDataTablesource JSDoc@exampleblocks so the regenerated.d.ts,llms.txt, and agent component manifest teach canonical patterns instead of throwaway ones: the two empty states (anEmptyhosted inDataTable.EmptyRow— "no data yet" vs. "no results for the active filter", with aClear filtersreset) and pagination viaCursorPaginationwith a page-size dropdown (replacing hand-rolled prev/nextButtons). No runtime API changes. -
#1249
44ee551Thanks @cody-dot-js! - Drive thescroll-fade-xedge-fade utility with a CSS scroll-driven animation instead of JS-toggleddata-scroll-left/data-scroll-rightattributes, and add a matchingscroll-fade-yutility 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).TableandTabsnow render their horizontal edge fades purely in CSS: theTabs.Listeffect no longer runs a scroll listener,ResizeObserver, orMutationObserverfor the fade (it only keeps a keyboard-focused trigger scrolled into view), andTableno 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). Whereanimation-timeline: scroll()is unsupported (Firefox stable as of mid-2026) the fade is simply absent — content is never clipped. -
#1248
3a234e0Thanks @cody-dot-js! - Update Radix UI dependencies to their latest patch/minor releases:@radix-ui/react-accordionto1.2.14,@radix-ui/react-dialogto1.1.17,@radix-ui/react-dropdown-menuto2.1.18,@radix-ui/react-hover-cardto1.1.17,@radix-ui/react-popoverto1.1.17,@radix-ui/react-progressto1.1.10,@radix-ui/react-selectto2.3.1,@radix-ui/react-sliderto1.4.1,@radix-ui/react-slotto1.3.0,@radix-ui/react-switchto1.3.1,@radix-ui/react-tabsto1.1.15, and@radix-ui/react-tooltipto1.2.10. No public API changes. -
#1239
9b00072Thanks @cody-dot-js! - Surface each component's JSDoc@exampleblocks 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:Sheetexamples usedopOpenChangeinstead ofonOpenChange, andSandboxedOnClick's example was missing its closing code fence. Also documents theFieldslot-order anti-pattern — help text (Field.Description) renders belowField.Control, not above it.
0.76.2
Patch Changes
- #1233
fa978b0Thanks @cody-dot-js! - RefineEmptyandWellstyling:Emptyuses tighter padding (p-6),Empty.Iconis smaller with a neutral color,Empty.TitleandEmpty.Descriptionadopt smaller sans-serif text with a muted description, andWelluses the muted card border.
0.76.1
Patch Changes
- #1229
a569ca3Thanks @cody-dot-js! - Remove the unused@uidotdev/usehooksdependency. It was never imported by any mantle code; consumers no longer install it transitively.
0.76.0
Minor Changes
-
#1227
fbec001Thanks @cody-dot-js! - fix(mantle): Dialog.Footer renders children in DOM orderDialog.Footerpreviously appliedflex-row-reverse, laying its children out right-to-left so the DOM order was the visual mirror of the source. It now usesflex justify-endand renders children in DOM order, matchingSheet.FooterandAlertDialog.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
6b22e51Thanks @cody-dot-js! - feat(mantle): add Well componentA recessed, inset container that renders like
Card.Rootbut with the base background and an inset shadow, for grouping and de-emphasizing content such as empty states, code samples, and read-only summaries. SupportsasChildfor polymorphic rendering.
0.74.0
Minor Changes
- #1221
100debfThanks @cody-dot-js! - Add theQrCodecomponent — a scannable QR code that encodes itsvaluewith the dependency-freeuqrlibrary. ComposeQrCode.Rootwith aQrCode.Framewrapping aQrCode.Pattern, plus an optionalQrCode.Overlayfor 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 thequietZoneprop (defaults to the spec-recommended4).
Patch Changes
- #1223
6dc792cThanks @cody-dot-js! - Update dependencies:@ariakit/reactto 0.4.29,@radix-ui/*packages (accordion, dialog, dropdown-menu, hover-card, popover, progress, select, slider, slot, switch, tabs, tooltip), anddate-fnsto 4.4.0.
0.73.5
Patch Changes
- #1218
61d638dThanks @cody-dot-js! -mantleCodetemplates now support nested fragment interpolation: interpolating another top-levelmantleCodevalue'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
b9fadcaThanks @dependabot! - Lint cleanup round across@ngrok/mantle. No public-API changes.Checkbox: the underlying<input>no longer receives bothcheckedanddefaultCheckedat 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 DOMindeterminateflag is still applied via a ref effect.- Context providers in
Input,CursorPagination,Dialog,Tabs,Select,Toast, and thewwwnavigation context now memoize theirvalueprop viauseMemo, andHorizontalSeparatorGrouplifts 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-tabBroadcastChannellistener now usesaddEventListener("message", …)instead of assigning toonmessage, matching the modern event API used elsewhere in the file. Cleanup still goes throughBroadcastChannel.close().- Internal: tightened
vi.fngenerics 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 (notablybutton-has-typeon<button type={type}>andrequire-post-message-target-originonBroadcastChannel.postMessage).
-
#1213
5a2c3f0Thanks @cody-dot-js! - Breaking: flattened theCommand.Dialog.*namespace into top-level members onCommand.The nested
Command.Dialognamespace was a pass-through re-export ofDialog.Root/Dialog.Triggerplus theCommand-specificContentslot. 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/reactupgrades. 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, andMultiSelect. The default<XIcon />forAlert.DismissIconButton, the default<QuestionIcon />forField.HelpTrigger, and the default[]values forMultiSelect.Root'sdefaultSelectedValueandMultiSelect.TagValues'slockedValuesare now hoisted to module scope so they keep referential equality across renders. - Dependency bumps:
@ariakit/react0.4.26 → 0.4.28,@types/react19.2.14 → 19.2.15,date-fns4.1.0 → 4.3.0 (peer also bumped to^4.3.0).
- Stable default prop references in
0.73.3
Patch Changes
-
#1203
f248067Thanks @cody-dot-js! - AddField.LabelText— static, label-styled text forField.Itemrows that do not caption a focusable form control. Renders a<p>(not a<label>) with the sametext-strong text-sm font-mediumtypography asField.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. SupportsasChildfor polymorphic rendering. For rows with a real focusable control, keep usingField.Labelso the label-to-control association stays wired.
0.73.2
Patch Changes
-
#1200
c4e968eThanks @cody-dot-js! - MakeField.Itemthe single source of truth for the control'sname,id,aria-*, andvalidation. When a control is wrapped inField.*, all four are owned by the surroundingField.Itemand overwrite anything passed on the child orField.Control. To override, set the prop onField.Itemitself; to opt out of the contract entirely, render the control withoutField.Control. The pit of success is "useField.*and let it wire everything up."Required
nameonField.Item, automatic id/htmlForField.Itemnow requires aname: stringprop.Field.Controlsplats the surroundingField.Item'snameand a stable generatedidonto its focusable child. Anynameoridpassed on the child is intentionally overwritten.Field.Labeldefaults itshtmlForto that same generated id when rendered inside aField.Item, so labels wire to their controls without a manualhtmlFor/idpair (or a separateuseId()call). PasshtmlForonField.Labelto opt out (e.g. when the focusable element is rendered outside ofField.Control).
This is a particularly tight fit with TanStack Form's
field.name:One policy for overrides
Field.Controlno longer accepts avalidationprop, and no longer readsaria-invalidoff the child element. SetvalidationonField.Itemto opt out of inferred error state from renderedField.Errors/Field.ErrorList. This collapses the previous three policies (child wins foraria-invalid, context wins foraria-describedby/aria-errormessage,Field.Controlwins forvalidation) into one:Field.Itemowns it.Field.Controlaround compound widgets (Select, etc.)Field.Controlnow publishes the resolved ARIA props through a new internalFieldControlContext.Select.Triggerconsumes it foraria-describedby/aria-errormessage(whichSelect.Rootdoesn't forward to its inner trigger), so the natural compositionworks end-to-end —
namelands onSelect.Root's hidden form input, and the field's helper / error IDs reach the focusable trigger button.Slidernow forwardsaria-label/aria-labelledbyto its rendered thumb(s), and its Field examples pass an explicitaria-label. Slider thumbs are ARIA slider widgets rather than native labelable controls, soField.Labelprovides the visible caption while the slider prop provides the accessible thumb name.Duplicate error messages
Field.ErrorsandtoErrorMessagesnow 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
2c00e7cThanks @cody-dot-js! - AddtoErrorMessages, a first-class helper exported from@ngrok/mantle/fieldfor normalizing TanStack React Form's mixedfield.state.meta.errorsarray into a cleanstring[]forField.Errors. Folds together the shapes TanStack Form yields across its built-in validators — plain strings,{ message }issue objects (Zod / Standard Schema), thrownErrorinstances, and the falsy slots Standard Schema can produce — into trimmed, non-empty strings without coupling Mantle to a specific form library.Also exports the
FieldErrorinput 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
e09de2dThanks @cody-dot-js! - AddField, 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 plusField.Description,Field.Optional, andField.ErrorListsupportasChild;Field.Set,Field.Legend, andField.ErrorItemalways render<fieldset>,<legend>, and<li>respectively so their core semantics cannot be accidentally removed.The default tree is a
Field.GroupofField.Items — that's all most forms need. Reach for the semanticField.Set+Field.Legendonly 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. StacksLabel, control (Input,Select,Checkbox, etc.), and anyField.Description/Field.Errors/Field.ErrorListsiblings vertically withgap-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 multipleField.Items vertically withgap-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 withRadioGroup(one question, many answers) or related checkboxes (preference toggles, permission flags). Skip it for unrelated fields stacked together —Field.Groupis the right call there.Field.Legendowns its ownmb-1.5(instead of relying on the parent fieldset's flexgap, which<legend>ignores in browsers) so the legend sits 6px above the next sibling — override with anymb-*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.LabelRowis the way out.Field.Help/Field.HelpTrigger/Field.HelpContent— thin wrappers overPopover+IconButton+ a default PhosphorQuestionIconso the help-affordance pattern stays concise while still requiring a contextual accessible label.Popover(notTooltip) so the affordance is reachable on touch.Field.HelpTriggercarries a default-my-0.5so the 24pxxsbutton keeps its full click target while contributing only 20px to theField.LabelRowflex line — matching the label's text line-height so the label is not pushed off-center. Override with anymy-*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 aField.Item.Field.Errors— convenience renderer for string validation messages. Trims messages, filters empty values, and renders a semanticField.ErrorList/Field.ErrorItemtree 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. EmptyField.ErrorItemchildren collapse so message-less validator results do not produce empty referenced list items.
Field parts forward refs through React 18's
forwardRefAPI so refs land on the rendered DOM element, including supportedasChildsubstitutions.Field.Itemauto-generates stable IDs for descendantField.Description/ non-emptyField.Errors/Field.ErrorListparts, then merges those IDs onto known form controls viaaria-describedby/aria-errormessagewithout changing the control's ownidorname, soid={field.name}remains the right TanStack Form pattern.When a
Field.DescriptionfollowsField.ErrorsorField.ErrorList, the parent'sgap-1.5auto-collapses via-mt-1.5so the error list and the trailing helper read as a single tight block. Pass any margin utility onField.Descriptionto override — the rule's specificity is flattened to(0,1,0)so a user class wins.Switchnow acceptsvalidationand reads the ambientField.Item/Field.Controlstate, 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.Controlnow throws a descriptive error when its child is not a valid React element or a render-prop function, instead of crashing insideSlotwith an opaque "expected a single child" message.
Patch Changes
-
#1195
fb7d6f3Thanks @cody-dot-js! - Bumptailwindcssand@tailwindcss/viteto4.3.0, andtailwind-mergeto3.6.0. -
#1193
985659fThanks @cody-dot-js! -CodeBlock.CopyButtonnow accepts an optionallabelprop for the accessible (screen-reader) label, defaulting to"Copy code". -
#1189
e2c07e5Thanks @cody-dot-js! -CodeBlock.Codeno longer paints a browser-default focus outline on its<pre>, and no longer hardcodestabIndex={-1}on the element.The
tabIndex={-1}default was a leftover workaround from when Prism.js auto-injectedtabIndex={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 settabIndex={-1}(or any other value) per instance if they want to exclude a block from the tab order.The
outline-hiddenis 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-visiblestyles. -
#1185
72b52b1Thanks @cody-dot-js! -Labelnow defaults tofont-mediumwhen 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-suppliedfont-bold/font-normal/font-semiboldoverrides 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
40cb820Thanks @cody-dot-js! -Dialog.BodyandSheet.Bodynow reserve space for the scrollbar viascrollbar-gutter: stable, preventing horizontal content shift when the body grows past its container and a scrollbar appears.
0.72.0
Minor Changes
-
#1174
51a0864Thanks @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 (
parse5withsourceCodeLocationInfo) — 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
shellscriptgrammar: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
rubygrammar:def,class,module,begin,if,unless,while,until,for,case, anddo |args|blocks, all closing onend. Mid-block keywords (else,rescue,when) deliberately don't pop, sobegin … rescue … endis a single fold.
New exports on
@ngrok/mantle-server-syntax-highlighter:computeServerFoldRanges({ code, language, tokens })— main dispatcher, called byhighlightWithMantleShiki.serverFoldStrategyFor(language)/serverFoldNeedsTokens(strategy)— let callers control whether to capture Shiki'sincludeExplanation: 'scopeName'tokens.computeJsxFoldRanges,computeHtmlFoldRanges,computeCssFoldRanges,computeKeywordFoldRanges— per-strategy computers, individually testable.
@ngrok/mantle/highlight-utilsand@ngrok/mantle/code-blocknow also re-exportfinalizeFoldRangesso consumers building custom strategies can reuse the dedup pass.Skipping
includeExplanationon 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. - JS / TS / JSX / TSX (
-
#1174
51a0864Thanks @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-parseremits 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) —parse5source 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 asdef,class,module,begin,do,if, andcase, all closing onend. - No folding (plain text) — text-like languages have no block model to fold.
New helper exports from
@ngrok/mantle/code-blockand@ngrok/mantle/highlight-utils:computeFoldRanges({ language, tokens })— generic fold-range computer that dispatches to the right strategy. Consumes Shiki tokens withincludeExplanation: 'scopeName'.foldStrategyFor(language)— returns'bracket' | 'indentation' | 'tag' | 'none'for a supported language.- Types:
ComputeFoldRangesInput,FoldStrategy,FoldLine,FoldToken,FoldExplanation,FoldScope.
computeFoldRangesis a low-level token helper for custom integrations; the Vite plugin, MDX code fences, and server highlighter use the richer@ngrok/mantle-server-syntax-highlighterdispatcher for AST, raw-source, and keyword folding.The existing
computeJsonFoldRangescontinues to work for callers that already use it on raw JSON source.Also fixes a latent bug where toggling
CodeBlock.ExpanderButtonwould replace the highlighted<code>'s child DOM (becausedangerouslySetInnerHTMLreceived 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. - AST-based (
Patch Changes
-
#1174
51a0864Thanks @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 pressingEnter/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-blockand@ngrok/mantle/highlight-utils:computeJsonFoldRanges(code)and theFoldableRangetype. decorateHighlightedHtmlaccepts a new optionalfoldableRangesinput. 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
5174b31Thanks @cody-dot-js! -OtpInput.Rootnow accepts avalidationprop ("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 ofInput.validation="error"additionally setsaria-invalidon the underlying input so assistive tech announces the failure state.Internal:
ProgressDonutnow uses the$cssPropertieshelper for inline CSS-variable styles instead of anas CSSPropertiescast — no behavior change.
0.71.1
Patch Changes
-
#1170
5c7ea22Thanks @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.mdparsed 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/*.jsonpayload.
Each entry in
/api/components.jsonnow also carries ajsdocfield with the first sentence of the source JSDoc on the component's primary export.The published package now ships
@ngrok/mantle/agent.jsonand@ngrok/mantle/llms.txt— pointers to the live endpoints above plus the package's own subpath inventory — for agents working without network access. -
#1168
9da2cacThanks @cody-dot-js! - ReduceOtpInput.Slottransition duration from 300ms to 150ms for snappier focus feedback.
0.71.0
Minor Changes
- #1167
acd0c55Thanks @cody-dot-js! - AddOtpInput, a compound component for capturing one-time passcodes (OTP). Built on top ofinput-otp, it renders a single hidden input that handles paste, autofill, and IME correctly while displaying each character in its own styled slot. Composes asOtpInput.Root>OtpInput.Group>OtpInput.Slotwith optionalOtpInput.Separatorbetween groups. Re-exports theREGEXP_ONLY_DIGITS,REGEXP_ONLY_CHARS, andREGEXP_ONLY_DIGITS_AND_CHARSpattern helpers for restricting accepted characters.
Patch Changes
-
#1166
4f35862Thanks @cody-dot-js! - Significantly expand JSDoc coverage across the package to improve agentic and IntelliSense-driven discovery. Everyindex.tsbarrel now ships a@seelink 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@exampleblocks across sub-components, and explicit notes on required setup (TooltipProvidermount,useReactTableinstance forDataTable.Root, etc.). Also removes the empty"./date-picker"export frompackage.json(it had no implementation and was never importable). No runtime behavior changes. -
#1161
913acb6Thanks @cody-dot-js! - Expand JSDoc coverage onSkeleton,Badge,Code,Kbd,MediaObject,Anchor,Flag,Label,PasswordInput, and the top-levelDataTable(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
0f0b607Thanks @dependabot! - Updatetailwindcssand@tailwindcss/viteto4.2.4.
0.70.2
Patch Changes
-
#1152
01b04f4Thanks @forzalupo! - Clean upCodeBlockcopy button placement and wrapper sizing so it aligns consistently within the code block. -
#1160
60d8c53Thanks @cody-dot-js! -DataTable.Rownow auto-appliescursor-pointerwhen anonClickhandler is provided, so consumers no longer need to add it manually. Pass a differentcursor-*class viaclassName(for example,cursor-wait) to override. -
#1160
60d8c53Thanks @cody-dot-js! - FixuseComposedRefsreturning 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
60d8c53Thanks @cody-dot-js! - Breaking:useCopyToClipboardnow returns the async copy function directly instead of a[state, copyFn]tuple. The internaluseStatethat tracked the last copied value has been removed, eliminating an extra render per successful copy.
0.70.1
Patch Changes
-
#1148
6085190Thanks @cody-dot-js! - Add missingdata-slotattributes across the component library so every exported component and compound sub-part exposes a stabledata-slot="<name>"(ordata-slot="<name>-<part>") styling hook. Also addsasChildsupport (viaSlot) toEmpty.Root,Empty.Actions,Code, andButtonGroup.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, andProgressDonut.HoverCard.Trigger,Dialog.Trigger/Dialog.Close, andDropdownMenu.Triggerare now thin wrappers around their underlying Radix primitives so they can carry adata-slotattribute.Note:
ProgressBar's existingdata-slotvalues were renamed to follow the compound-component naming convention —data-slot="progress"→data-slot="progress-bar"anddata-slot="progress-indicator"→data-slot="progress-bar-indicator". Consumers styling against those specific values will need to update their selectors. -
#1150
e623364Thanks @cody-dot-js! - Anchors (<a>elements, including those rendered by theAnchorcomponent) that are descendants ofAlert.Rootnow inherit the alert's variant text color (e.g.text-warning-700inside 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.Rootwill see those links shift from the defaulttext-accent-600to the alert's priority color, and the underline will be persistent rather than hover-only. -
#1150
e623364Thanks @cody-dot-js! - Add "when to use" guidance and WAI-ARIA cross-references to theTooltip,Popover, andHoverCardcompound-namespace JSDoc so editor hover tips surface the correct component for each use case.
0.70.0
Minor Changes
-
#1147
5e3aca7Thanks @cody-dot-js! - AddSkipToMainLinkandMaincomponents for accessible "skip to main content" navigation.SkipToMainLinkis 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).Mainrenders a focusable<main id="main" tabIndex={-1}>landmark designed to pair with it.Also registers a new
z-maxutility (backed by--z-index-max: 2147483647, i.e. int32 max) so consumers can stack elements above any other z-index layer.
Patch Changes
- #1107
4d35209Thanks @forzalupo! - Internal: switch=== undefined/!== undefinedchecks to== null/!= nullfor consistency with the project's nullish-equality style. No behavior change.
0.69.2
Patch Changes
- #1126
545a09dThanks @dependabot! - Bump @ariakit/react from 0.4.25 to 0.4.26
0.69.1
Patch Changes
-
#1141
f82feb8Thanks @cody-dot-js! - Add aCompositionsection to every compound component doc page showing the structural tree of its parts (ASCII art). Rename existingasChild-styleCompositionsections onBadge,Button,IconButton,Separator, andSplitButtontoPolymorphism. Rename theInputchildren-composition section toChild Elementsand theDialogtooltip section toCombining with a Tooltipto avoid colliding with the newCompositionname.Add the same ASCII composition tree as a
@example Compositionblock 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
f82feb8Thanks @cody-dot-js! - Expand JSDoc coverage acrossCodeBlock,Command,Dialog,Empty,Popover,Select, andSheetcompound components and their sub-parts for improved IntelliSense and documentation. -
#1141
f82feb8Thanks @cody-dot-js! - Fix several code fence metastring parsing bugs inmantleCodeRehypePlugin:mantleShowLineNumbers,mantleCollapsible, andmantleDisableCopyare now stringified on HAST<pre>properties. Previously, booleanfalsevalues were dropped during HAST→JSX compilation, causingshowLineNumbers=falsefence meta to be silently ignored (the rendered<pre>ended up withdata-mantle-line-numbers="true"and no left padding).collapsible=true,collapsible=false, andcollapsible="true"in fence meta are no longer silently dropped. Thecollapsiblekey now accepts the same bare-flag, key-value, and quoted-value forms asdisableCopy.- When a key appears multiple times in fence meta (e.g.
title="first" title="second"),getMetaValuenow returns the last value, matchingparseMetastringsemantics. Previously it returned the first. tokenizeMetastringnow splits on tabs, newlines, and carriage returns in addition to spaces. Previously only literal spaces were treated as token separators, so meta liketitle="Foo"\tcollapsiblewas lexed as a single token.
0.69.0
Minor Changes
- #1138
3f06774Thanks @cody-dot-js! - Add newEmptycompound component for rendering empty states. IncludesEmpty.Root,Empty.Icon,Empty.Title,Empty.Description, andEmpty.Actionssub-components.
0.68.7
Patch Changes
- #1134
5bde2bbThanks @cody-dot-js! - Fix InputContainer focus ring flicker by preventing mousedown default on non-input children. UseflushSyncin PasswordInput to animate the eye icon inline in the click handler after React commits the new icon to the DOM. Removepointer-events-nonefrom validation feedback icons.
0.68.6
Patch Changes
- #1132
c0ea674Thanks @cody-dot-js! - Fix Input focus ring showing as black when clicking the PasswordInput visibility toggle by usingfocus-withinfor both ring size and color. Add a blink animation to the PasswordInput eye icon toggle that respectsprefers-reduced-motion.
0.68.5
Patch Changes
-
#1130
8c7866bThanks @cody-dot-js! - fix(table): switch to border-separate for continuous sticky column indicatorReplaced
border-collapsewithborder-separate border-spacing-0onTable.Elementand moved row dividers from group-level borders (divide-yon<thead>/<tbody>/<tfoot>) to cell-level borders. This prevents table cells from clipping overflow content, allowing theStickyColIndicatorshadow strip to extend across row boundaries and render as one continuous vertical line instead of per-row segments with visible gaps.Also changed
overscroll-nonetooverscroll-x-noneon the table scroll container so vertical page scrolling is no longer blocked when hovering over a table.
0.68.4
Patch Changes
-
#1122
327d46cThanks @cody-dot-js! - Fix table horizontal scroll masking, improve scroll perf, and addDataTable.ActionHeader.Table.Root: Split into outer wrapper (border, rounded corners, background) and inner scroll container (scroll-fade-xmask,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-cellbox-shadowwithbg-inheritand aStickyColIndicatorchild span (1px divider + soft leftward gradient). Tracks the row's background including hover state.DataTable.ActionHeader(new): Sticky<th>that pairs withDataTable.ActionCellso the pinned action column aligns across header and body rows during horizontal scroll.useHorizontalOverflowObserver: Now tracksscrolledToStart, coalesces rapid-fire scroll/resize/mutation events viarequestAnimationFrame, and usesuseLayoutEffectso corrections apply before the browser paints.
See
migrations/data-table-action-header-migration.mdfor migration guidance.
0.68.3
Patch Changes
- #1119
0c20cf7Thanks @cody-dot-js! - DefaultshowLineNumberstotrueinmantleCode()andhighlightWithMantleShiki()so code blocks show line numbers by default. Single-line shell snippets (bash,sh,shell) default tofalsesince line numbers add noise to one-liners like install commands.
0.68.2
Patch Changes
- #1116
5b0be5aThanks @cody-dot-js! - no-op patch to verify publish CI
0.68.1
Patch Changes
-
#1114
d46e3b3Thanks @forzalupo! - Improve contrast in code block syntax highlighting -
#1112
ad24f11Thanks @cody-dot-js! - Add README documentation for npm packages, switch to ES2025 preset
0.68.0
Minor Changes
- #1067
ab6da43Thanks @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
4a81875Thanks @cody-dot-js! - Bump dependencies: @ariakit/react to 0.4.24, browserslist to 4.28.2, tailwindcss peer to ^4.2.2 -
#1082
383d538Thanks @dependabot! - Update ariakit and headlessui -
#1104
2be1db1Thanks @forzalupo! - Polish component styles: soften card/popover/dialog hover backgrounds withcolor-mix(), update SplitButton menu item icon placement and gap, usebg-active-menu-itemin Command palette, reduce table header height, improve Combobox item icon sizing, and flatten DataTable ActionCell layout. -
#1106
41ce842Thanks @forzalupo! - Themed code block with granular syntax highlighting: richer Shiki token color palette, semantic background tokens (bg-card/bg-base), redesigned copy button usingIconButton, 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
f27c01fThanks @cody-dot-js! - CodeBlock: replace PrismJS with Shiki for build-time syntax highlighting, removing Shiki/Prism from the browser bundle
Patch Changes
-
#1089
36e5921Thanks @cody-dot-js! - Tabs: use fade mask instead of shadow for horizontal overflow -
#1089
36e5921Thanks @cody-dot-js! - fix(tabs): correct border width and focus ring clipping in horizontal classic tabs -
#1089
36e5921Thanks @cody-dot-js! - fix(tabs): remove bottom border from horizontal classic tab list -
#1088
54743f1Thanks @forzalupo! - MultiSelect: polish styling
0.66.17
Patch Changes
-
#1071
c32a7d2Thanks @dependabot! - Bundle prismjs into the code-block dist output to fixReferenceError: Prism is not definedwith Vite 8 / Rollup 4. Previously, prismjs component files (plain IIFEs with nomodule.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 beforewindow.Prismwas set. Bundling prismjs directly resolves the ordering issue transparently for all consuming apps. -
#1086
537de55Thanks @cody-dot-js! - Add horizontal overflow scrolling toTabs.Listwith 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
582bec9Thanks @cody-dot-js! - Add--spacing-em(1em) theme token for font-relative sizing utilities likesize-em,w-em,h-em -
#1076
582bec9Thanks @cody-dot-js! - Add defaultwidth="2.61em" height="1em"toNgrokWordmarkIconandwidth="1em" height="1em"toNgrokLettermarkIconso they render at font size without requiring explicit sizing classes
0.66.15
Patch Changes
- #1074
284aaa9Thanks @cody-dot-js! - Update NgrokLettermarkIcon dimensions
0.66.14
Patch Changes
- #1072
5dcc9beThanks @cody-dot-js! - RenameNgrokIcontoNgrokWordmarkIconand add newNgrokLettermarkIcon
0.66.13
Patch Changes
- #1061
1b0e722Thanks @dependabot! - Bump@ariakit/reactdependency.
0.66.12
Patch Changes
-
#1064
24ffd25Thanks @cody-dot-js! - DecomposeCommand.Dialoginto aRoot/Trigger/Contentnamespace.Breaking change:
Command.Dialogis 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.Triggersub-component allows opening the dialog from a button without managingopenstate manually:Also fixes two bugs:
Command.Dialognow correctly closes on Escape key press.Command.Separatornow automatically hides when all adjacent groups are filtered out.
0.66.11
Patch Changes
- #1057
930e5fbThanks @cody-dot-js! - ExportfixMediaScriptContentfrom@ngrok/mantle/theme. This function returns the raw JavaScript string for the inline<script>that fixes theme stylesheetmediaattributes — 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
714c79eThanks @cody-dot-js! - fix(use-copy-to-clipboard): remove unnecessary async wrapper, guarantee polyfill DOM cleanup
0.66.9
Patch Changes
-
#1048
ca1ce98Thanks @cody-dot-js! - fix(code-block): restore syntax highlight token styles to mantle.cssThe
.token.*styles were moved out ofmantle.cssinto a colocatedsyntax-highlight.cssimported as a side-effect incode-block.tsx. However, tsdown extracts CSS imports into a sidecar file without emitting a correspondingimportin the bundled JS, so consuming apps never loaded the styles. Moved the token styles back intomantle.csswhere they are reliably included.
0.66.8
Patch Changes
-
#1038
00fda43Thanks @cody-dot-js! - Breaking:MantleStylesheetsis renamed toMantleStyleSheets(andMantleStylesheetsProps→MantleStyleSheetsProps).The component no longer imports CSS
?urlpaths internally. Instead, pass the browser-accessible CSS URLs as required props using the newmantleStyleSheetUrlshelper, which collects the three Vite?urlimports into a spreadable object:This fixes a build error (
Unknown file extension ".css") in apps using React Router's SSR build, where?urlimports inside node_modules are not transformed by Vite.When
forceThemeis 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
1521814Thanks @cody-dot-js! - Move syntax highlight token styles into the code-block componentThe
.token.*CSS rules for syntax highlighting were previously included unconditionally inmantle.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.cssand imported as a side-effect fromcode-block.tsx. Vite bundles them as a CSS chunk associated with the code-block module — apps that never import@ngrok/mantle/code-blockno longer pay the cost, and apps that do get the styles automatically. -
#1036
1521814Thanks @cody-dot-js! - AddMantleStylesheetscomponent and split dark/high-contrast themes into separate CSS filesNew:
MantleStylesheetsA new component exported from
@ngrok/mantle/themethat renders<link rel="stylesheet">tags for the dark, light-high-contrast, and dark-high-contrast theme CSS files. Each stylesheet is gated behind amediaattribute matching its OS preference, making it non-render-blocking for users whose OS does not match.Place it in
<head>, immediately after<PreventWrongThemeFlashScript>:Props:
forceTheme?: ResolvedTheme— force a specific theme's stylesheet tomedia="all"unconditionally (e.g. for a dark-only app)ssrCookie?: string— pass the extracted theme cookie (viaextractThemeCookie) so the server renders the correctmediaattribute directly in SSR HTML, eliminating FOUC for users with a manually-selected non-system themenonce?: 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 anymediaattributes based onhtml[data-applied-theme]set byPreventWrongThemeFlashScript, covering cases wheressrCookieis not provided. On the client, aMutationObserverwatcheshtml[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
@importchain —MantleStylesheetshandles loading them via<link>tags.mantle.csscontinues to hold the light theme and all Tailwind directives and must remain in your CSS@importchain as before. -
#1036
1521814Thanks @cody-dot-js! - Add@ngrok/mantle-vite-pluginspackage andsource-all.css, optimizemantle.cssNew:
@ngrok/mantle-vite-pluginsA new package that exports
mantleSourcePlugin— a Vite plugin that writes targeted Tailwind@sourcedirectives into your global CSS file for only the@ngrok/mantlecomponents your app actually imports. Scans.ts,.tsx,.js,.jsx,.mdx, and.mdfiles. Uses a disk-write approach (required because@tailwindcss/vitereads CSS from disk at startup before Vite's transform pipeline runs).New:
@ngrok/mantle/source-all.cssA zero-configuration alternative to
mantleSourcePlugin. Import it alongsidemantle.cssto tell Tailwind to scan all mantle component dist files:Use this for apps that import most or all mantle components. Use
mantleSourcePluginfor apps that import a subset and want the smallest possible CSS output.mantle.cssoptimizations- Removed
@source "../dist"— source scanning is now opt-in viasource-all.cssormantleSourcePlugin - Deduplicated
--color-gray-*in light/dark themes usingvar(--color-neutral-*)aliases - Removed 21 light-theme color overrides (
neutral-50–neutral-900, allred-*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 likebg-background-color-base
- Removed
0.66.6
Patch Changes
- #1034
949ec0fThanks @cody-dot-js! - Updatefont-displaypolicy for core fonts:roobertandjetbrains-mono(normal) useswap, italic faces (jetbrains-mono-italic,family-italic) useoptional,family-regularstaysfallback.
0.66.5
Patch Changes
-
#1032
b9a04ccThanks @cody-dot-js! - AddpreloadFontLink(name: CoreFontName): string— returns an HTTPLinkheader 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
MantleThemeHeadContentandPreloadCoreFonts.Migration: use
PreventWrongThemeFlashScriptin<head>andpreloadFontLinkin your HTTP response headers. For apps without header control, use individualPreloadFontelements instead.
0.66.4
Patch Changes
- #1030
2b740bbThanks @cody-dot-js! - export PreventWrongThemeFlashScript
0.66.3
Patch Changes
-
0dac608Thanks @cody-dot-js! - Improve code block utility performance by speeding up indentation normalization andfmtCode, 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 tonormalize-indentation, and guard line-number expansion against excessively large ranges. -
#1028
11f4575Thanks @cody-dot-js! - add individual PreloadFont component, preload a core font by name
0.66.2
Patch Changes
-
#1023
bc1bc6cThanks @cody-dot-js! - multi-select.tsx- close the combobox popover on
Escapeat the multi-select trigger level
dialog/primitive.tsx
- centralize
onInteractOutside/onPointerDownOutsideprompt-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/onPointerDownOutsidewrappers
sheet.tsx
- remove duplicated
onInteractOutside/onPointerDownOutsidewrappers
alert-dialog.tsx
- remove duplicated
onInteractOutside/onPointerDownOutsidewrappers
- close the combobox popover on
0.66.1
Patch Changes
-
#1004
b04e08fThanks @dependabot! - Update dependencies: tailwind, ariakit, react-day-picker -
#1021
aab7d30Thanks @cody-dot-js! - Swap from tsup to tsdown (6.2x speedup) -
#1022
df6d158Thanks @cody-dot-js! - FixMultiSelect.Contentinside modalSheet,Dialog, andAlertDialogcontainers so it works with the default modal behavior without requiringmodal={false}.
0.66.0
Minor Changes
- #997
612171bThanks @cody-dot-js! - Add MultiSelect component
0.65.0
Minor Changes
-
#1000
56a5245Thanks @cody-dot-js! - Add@ngrok/mantle/utilsexport and newuseInViewhook.Breaking changes:
@ngrok/mantle/compose-refsis removed. ImportcomposeRefsfrom@ngrok/mantle/utilsanduseComposedRefsfrom@ngrok/mantle/hooksinstead.@ngrok/mantle/utils/sortingis removed. Import sorting utilities from@ngrok/mantle/utilsinstead.
New exports at
@ngrok/mantle/utils:inView(element, onStart, options)— framework-agnosticIntersectionObserverhelper that callsonStartwhen an element enters the viewport. IfonStartreturns a function, it is called when the element leaves. Returns a cleanup function that disconnects the observer.composeRefsanduseComposedRefs— 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 returnstruewhen the referenced element is visible in the viewport. Supportsroot,margin,amount,once, andinitialoptions.
Patch Changes
-
#1001
922a053Thanks @cody-dot-js! - Address review feedback from PR #1000.inView: callobserver.unobserve(element)beforeobserver.disconnect()in the cleanup function for cleaner teardown.useInView: restore intentional omission ofisInViewfromuseEffectdeps (matching the upstream motion implementation); replace the bare eslint-disable comment with an explanation of why includingisInViewwould cause the observer to restart on every visibility change.tsup.config.ts: excludecompose-refsandsortingfrom individual build entries since they are now consolidated into the./utilsexport.hooks.mdx: fix missingforwardRefimport in theuseComposedRefscode example.hooks.mdx: removeinViewfrom the hooks page; it now has a dedicated page under the new Utils section.hooks.mdx: add a live interactive demo foruseInView.- Changeset: correct version bump type to
major(breaking changes) and clarifyuseComposedRefsmigration path to@ngrok/mantle/hooks. - Add
use-in-view.test.tsxtest coverage for theuseInViewhook. - Add a Utils section to the docs site with dedicated pages for
cx,color,inView,composeRefs, andsorting.
-
#1002
f6e8271Thanks @cody-dot-js! - Add optionaliconprop toAlert.DismissIconButton.The dismiss button now accepts an
iconprop (ReactNode) to render a custom icon in place of the defaultXicon. -
#999
4ebf12bThanks @dependabot! - Bump tsconfig target/lib to ES2023 -
#994
c4d9812Thanks @dependabot! - Bump up min version of tailwind
0.64.3
Patch Changes
-
#990
16c731aThanks @cody-dot-js! - update to tailwind 4.2.0 -
#988
7e59d87Thanks @cody-dot-js! - Update dropdown, select, and combobox menu item styling
0.64.2
Patch Changes
- #985
ce626ccThanks @cody-dot-js! - Slider: Addcolorprop to customize the range fill color (defaults to"bg-accent-500"), andshowTicksprop to render tick marks at eachstepinterval. Also adds--color-card-borderand--color-card-border-mutedsemantic color tokens.
0.64.1
Patch Changes
-
#980
c2735edThanks @dependabot! - Fix accessibility and linting issues inInputandRadioGroup.InputSandbox: replacearia-disabled/aria-invalidCSS hooks on wrapper divs withdata-disabled, addrole="none"to presentational wrapper divs -
#984
b3a32ecThanks @cody-dot-js! - Changefont-displayfromswaptofallbackin all@font-facedeclarations to eliminate layout shift (font bounce) while still downloading and caching fonts for subsequent loads -
#981
e43df1cThanks @cody-dot-js! - Bump up Slider thumb shadow to shadow-md (from shadow-sm) -
#980
c2735edThanks @dependabot! - Update tailwind-merge
0.64.0
Minor Changes
-
#974
623935cThanks @cody-dot-js! - RemoveAutoScrollToHashcomponent anduseAutoScrollToHashhook. This also removesreact-routeras a peer dependency of@ngrok/mantle. If you were usingAutoScrollToHash, move the implementation into your app directly. -
#973
a8ff1c9Thanks @cody-dot-js! - Add NgrokIcon to Icons -
#979
68cc794Thanks @cody-dot-js! - Add new Slider component, supports single, range, and multiple use cases
Patch Changes
-
#971
5781aaeThanks @cody-dot-js! - Fix doc block links -
#978
286fab3Thanks @cody-dot-js! - Fix jerky accordion close animation. Moved spacing frompadding-topon the animated container tomargin-topon the first child so it collapses smoothly with the height animation. -
#974
623935cThanks @cody-dot-js! - Add explicit generic type parameters toisValidElementcalls inButtonandSlotfor React 19 type compatibility. React 19 changedReactElement.propsfromanytounknown, requiring explicit type annotations to access props safely. This change is fully backwards compatible with React 18. -
#978
286fab3Thanks @cody-dot-js! - FixCodeBlock.CopyButtontimeout lifecycle handling by clearing pending timers on unmount and before scheduling a new copy-state reset. -
#975
d9a7206Thanks @cody-dot-js! - Fix theme flash on hydration by reading the theme cookie during SSR.useInitialHtmlThemePropsnow accepts an optionalssrCookiestring 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
76e1afdThanks @cody-dot-js! - FixuseIsBelowBreakpointperformance regression during window resize. ThesubscribeandgetSnapshotfunctions passed touseSyncExternalStorewere recreated on every render, causing listener teardown/re-attach churn on each frame. They are now cached per breakpoint for referential stability. -
#969
76e1afdThanks @cody-dot-js! - Restore all@custom-variantdefinitions inmantle.cssthat 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 thewherevariant.
0.63.1
Patch Changes
- #966
d26aee3Thanks @cody-dot-js! - Fix mantle.css exports
0.63.0
Minor Changes
-
#963
1a68417Thanks @cody-dot-js! - Add "info" as a first-class semantic color (mirrors "accent") -
#965
fd7e2ebThanks @cody-dot-js! - Add new SplitButton component
Patch Changes
-
#963
1a68417Thanks @cody-dot-js! - Fix Anchor component target types to delegate to React's built-in types -
#963
1a68417Thanks @cody-dot-js! - Fix mantle.css theme syntax -
#963
1a68417Thanks @cody-dot-js! - Apply font-sans to components for consistent typography -
#962
030099bThanks @dependabot! - Update react-day-picker
0.62.2
Patch Changes
- #957
141490dThanks @cody-dot-js! - fix return type of $cssProperties
0.62.1
Patch Changes
- #955
bf79ae2Thanks @cody-dot-js! - DescriptionList styling fixes
0.62.0
Minor Changes
-
#954
7235e48Thanks @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
e095088Thanks @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
- #939
d2922d2Thanks @cody-dot-js! - DataTable.HeaderSortButton: remove scaling when active
0.61.2
Patch Changes
- #935
b03ef99Thanks @aaronshekey! - Add 2xs breakpoint (360px)
0.60.2
Patch Changes
-
#912
d6c1f46Thanks @aaronshekey! - Create fluid typography. Kill references to Inter. -
#902
da31911Thanks @dependabot! - Update runtime dependencies: @ariakit/react, react-day-picker -
#914
e704a7dThanks @cody-dot-js! - Update dependencies: react-day-picker and @ariakit/react -
#914
e704a7dThanks @cody-dot-js! - Toast: support keeping toasts open until manually dismissed by passingduration_ms <= 0orNumber.POSITIVE_INFINITY -
#902
da31911Thanks @dependabot! - Switch to node-24 as LTS
0.60.1
Patch Changes
- #900
5174f39Thanks @cody-dot-js! - Button, IconButton: scale w/ transition ease-out
0.60.0
Minor Changes
-
#896
b58edd7Thanks @cody-dot-js! - Add Slot component with automatic className mergingIntroduces a new
@ngrok/mantle/slotcomponent that wraps Radix UI's Slot with automatic Tailwind CSS className merging usingcx. 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
- #893
0507080Thanks @cody-dot-js! - Button, IconButton: add data-appearance, data-priority to DOM
0.59.1
Patch Changes
- #887
eeca62dThanks @cody-dot-js! - Fix tooltip jsdoc urls
0.59.0
Minor Changes
-
#885
918bdd9Thanks @cody-dot-js! - Promote Tooltip component out of previewThe Tooltip component has been promoted from preview to stable. This includes:
- Updated documentation route from
/components/preview/tooltipto/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
- Updated documentation route from
0.58.4
Patch Changes
-
#878
9ecfcd4Thanks @dependabot! - Update dependencies; bump min version of tailwind -
#883
e3d39efThanks @cody-dot-js! - move mantle.css to src/ and improve HMR- Moved
mantle.cssfrompackages/mantle/assets/topackages/mantle/src/to enable proper hot module reload during development - Updated
mantle.csspackage 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.csstodist/directory for production builds
- Moved
0.58.3
Patch Changes
- #879
8a66858Thanks @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
f8170b9Thanks @dependabot! - Update dependencies; add static white, static black, and ff00ff as colors
0.58.1
Patch Changes
- #871
61b3eeeThanks @cody-dot-js! - Update Kbd bg color
0.58.0
Minor Changes
- #869
e38e79aThanks @cody-dot-js! - Add Kbd component and documentation page for keyboard shortcuts
0.57.5
Patch Changes
- #864
aa7f600Thanks @cody-dot-js! - AddfilterandshouldFilterprops toCommand.Dialogfor custom filtering behavior
0.57.4
Patch Changes
-
#858
df04845Thanks @cody-dot-js! - fix: restore text-size-inherit utilityRe-added
text-size-inheritfont-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(nottext-inherit) to avoid conflicts with Tailwind's standardtext-inheritutility which controls color inheritance.Changes:
- Added
--text-size-inherit: inherit;to@theme inlineblock - Added
text-size-inheritto font-size class group in cx helper's tailwind-merge config - Added tests for proper class merging with text-size-inherit
- Added
0.57.3
Patch Changes
-
#856
20c2da9Thanks @cody-dot-js! - fix: enable text-mono font-size overrides with cx helperExtended the
cxhelper's tailwind-merge configuration to recognizetext-monoas 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 liketext-base,text-xl, etc.Before:
After:
Technical Details:
- Added
text-monoto thefont-sizeclass group inextendTailwindMergeconfig - Added tests verifying proper class merging behavior
- Updated table docs example to demonstrate font-size override
- Added
0.57.2
Patch Changes
-
#854
8819cb0Thanks @cody-dot-js! - fix: convert text-size-mono to text-mono for proper tailwind-merge supportChanged the custom
@utility text-size-monoto a proper Tailwind theme fontSize extension--font-size-mono, which generates thetext-monoutility class. This allowstext-monoto be properly overridden by other font-size utilities liketext-baseortext-xlwhen using thecxhelper.Breaking Change: Users need to replace
text-size-monowithtext-monoin their code.Before:
After:
0.57.1
Patch Changes
-
#852
d5cbefbThanks @cody-dot-js! - Command: export MetaKey component for platform-aware keyboard shortcutsExtract 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
- #849
a83221aThanks @cody-dot-js! - Remove EuclidSquare bold/semibold; export new theme font helper components
0.56.0
Minor Changes
0.55.5
Patch Changes
- #842
0bdcabaThanks @cody-dot-js! - IconButton: add in sr-only label as inner children when using asChild
0.55.4
Patch Changes
- #840
6d60f2eThanks @cody-dot-js! - Table: set overscroll-x-none on Table.Root
0.55.3
Patch Changes
-
#834
235593bThanks @dependabot! - Update ariakit and react-day-picker -
#837
ba79d27Thanks @cody-dot-js! - Theme: improve cookie parsing and reduce risk of FOUC, hydration issues
0.55.2
Patch Changes
- #828
5cdda38Thanks @cody-dot-js! - AutoScrollToHash + useAutoScrollToHash: switch to context provider, allow for programmatic scrollToHash()
0.55.1
Patch Changes
- #826
48ce32dThanks @cody-dot-js! - Add react-router to peer dependencies of mantle, document AutoScrollToHash peerDep on react-router
0.55.0
Minor Changes
- #825
d9dfa70Thanks @cody-dot-js! - Export AutoScrollToHash, useAutoScrollToHash, and useScrollBehavior
Patch Changes
- #823
05ca536Thanks @dependabot! - Update dependencies
0.54.3
Patch Changes
-
#813
30580b7Thanks @dependabot! - Bump tw-animate-css -
#815
dd1eb1fThanks @cody-dot-js! - Add visibilitychange handler for ThemeProvider
0.54.2
Patch Changes
- #810
8005c93Thanks @cody-dot-js! - Theme: fix cross-tab theme sync when the cookie changes
0.54.1
Patch Changes
- #808
1f943b7Thanks @cody-dot-js! - remove microtask in FOUC script
0.54.0
Minor Changes
- #806
c1162a4Thanks @cody-dot-js! - Switch from localStorage to cookie for theme preference storage primitive
0.53.0
Minor Changes
- #804
9286f3aThanks @cody-dot-js! - Rename theme-provider export to theme
Patch Changes
-
#803
165981fThanks @dependabot! - Update react day picker dependency -
#801
47ecff2Thanks @aaronshekey! - Add the xs breakpoint to the breakpoint component
0.52.9
Patch Changes
- #797
a5a1b91Thanks @cody-dot-js! - Dialog Overlay Primitive: add data-overlay attribute to Overlay primitive
0.52.8
Patch Changes
-
#790
2806bb4Thanks @cody-dot-js! - Combobox: fix scrolling presentation in content -
#790
2806bb4Thanks @cody-dot-js! - Combobox: bump up spacing between input and content -
#789
df7b954Thanks @dependabot! - bump headlessui version
0.52.7
Patch Changes
-
#777
a484cabThanks @cody-dot-js! - Button: remove the need for inner span -
#779
37f40bdThanks @cody-dot-js! - Button, IconButton: add active:scale-[0.97]
0.52.6
Patch Changes
-
#775
3519c45Thanks @cody-dot-js! - anchor: fix ring class on focus-visible -
#773
17dcb26Thanks @dependabot! - update tailwindcss min peer dep
0.52.5
Patch Changes
- #770
9f55e0eThanks @cody-dot-js! - Alert: when appearance="banner", also remove the top border
0.52.4
Patch Changes
-
#767
10899b2Thanks @dependabot! - Update tw-animate-css -
#768
b8cd5d3Thanks @cody-dot-js! - Alert: add appearance prop to support "banner" alerts
0.52.3
Patch Changes
- #763
fe24bbbThanks @cody-dot-js! - Card: fix how we render borders between Card.Header, Card.Body, and Card.Footer
0.52.2
Patch Changes
- #760
9b8ea0bThanks @melanieseltzer! - ProgressDonut: addindeterminateRotationSpeedprop to control spin speed. Fix defaultanimation-duration-[15s]so it no longer overrides consumer-providedanimation-duration-*classes.
0.52.1
Patch Changes
- #752
be2c37aThanks @cody-dot-js! - Combobox: fix ItemValue data-user-value bolding
0.52.0
Minor Changes
-
#748
2e7509cThanks @cody-dot-js! - add useIsBelowBreakpoint hook, improve performance of breakpoint hooks -
#745
9611901Thanks @cody-dot-js! - Rename InlineCode to Code -
#746
639eed2Thanks @cody-dot-js! - Add useIsHydrated hook -
#746
639eed2Thanks @cody-dot-js! - Add useBreakpoint hook -
#746
639eed2Thanks @cody-dot-js! - Add BrowserOnly component
Patch Changes
-
#743
9e2621cThanks @cody-dot-js! - Switch Alert, Dialog, and AlertDialog Description components to render a div instead of a p by default -
#746
639eed2Thanks @cody-dot-js! - Update hooks documentation for useMatchesMediaQuery and useCopyToClipboard
0.51.3
Patch Changes
- #740
bbfd6d4Thanks @cody-dot-js! - Accordion: fix export object for compound component definition
0.51.2
Patch Changes
- #733
55565dcThanks @dependabot! - Update react-day-picker and tw-animate-css
0.51.1
Patch Changes
- #731
cd19eb1Thanks @cody-dot-js! - DropdownMenu: support tooltips on disabled dropdown menu items
0.51.0
Minor Changes
- #730
e61565fThanks @cody-dot-js! - DataTable: remove DataTable.Rows convenience component; instead prefer to map over rows directly
Patch Changes
- #728
521d59fThanks @dependabot! - Update dependencies
0.50.4
Patch Changes
- #722
961e157Thanks @cody-dot-js! - DataTable: support passing fn as child to DataTable.Rows
0.50.3
Patch Changes
-
#719
7cab99cThanks @cody-dot-js! - Toast: for toast message, use font-body instead of font-sans -
#721
31c6ec0Thanks @cody-dot-js! - DataTable: export cell component too
0.50.2
Patch Changes
-
#717
024cf70Thanks @cody-dot-js! - Colors: fix dark mode swap of white and black as semantic colors -
#717
024cf70Thanks @cody-dot-js! - replace remaining hsl() usages with oklch() and --color-* css vars
0.50.1
Patch Changes
- #714
6507acfThanks @cody-dot-js! - Fix missing production dependency for tw-animate-css
0.50.0
Minor Changes (0.x breaking)
- #656
e0846f8Thanks @aaronshekey! - Migrate mantle to Tailwind v4
0.40.1
Patch Changes
- #711
3f531c5Thanks @cody-dot-js! - Update ariakit (combobox improvements)
0.40.0
Minor Changes (0.x breaking)
- #687
a3b49abThanks @cody-dot-js! - Implement composite named exports
0.32.3
Patch Changes
- #699
82f26dcThanks @cody-dot-js! - Add nonce prop to MantleThemeHeadContent, update doc blocks
0.32.2
Patch Changes
- #695
0b342d7Thanks @dependabot! - Update headlessui and sonner dependencies
0.32.1
Patch Changes
-
#678
c303597Thanks @cody-dot-js! - Add explicit displayNames to all mantle components -
#680
7b6b15aThanks @cody-dot-js! - add missing intellisense jsdocs to all mantle components
0.32.0
Minor Changes
- #675
4a65a5aThanks @cody-dot-js! - Add ProgressBar component
0.31.6
Patch Changes
- #670
bc3e6acThanks @cody-dot-js! - remove zod as a peerDependency of mantle
0.31.5
Patch Changes
- #662
1cfccd6Thanks @cody-dot-js! - Sprinkle use client everywhere
0.31.4
Patch Changes
- #660
2fddd06Thanks @cody-dot-js! - Change peerDependencies for react and react-dom
0.31.3
Patch Changes
-
#642
1bb8fddThanks @dependabot! - update dependencies -
#651
57d5b60Thanks @dependabot! - Update dependencies -
#659
1d43071Thanks @cody-dot-js! - Fix mantle react, react-dom peer dependencies range -
#658
ad402dbThanks @melanieseltzer! - Fix bug wheresetValueOnClickprop was destructured (to set a default) but then never passed along and used (so the consumer could never override it).
0.31.2
Patch Changes
- #643
148dd76Thanks @aaronshekey! - Fix breakpoint configuration to allow for arbitrary breakpoint
0.31.1
Patch Changes
- #635
baa2d7fThanks @cody-dot-js! - RadioIndicator: add shrink-0 so the indicator does not shrink in flex contexts
0.31.0
Minor Changes
-
#633
0632ccaThanks @cody-dot-js! - SelectItem: allow for icon slot -
#633
0632ccaThanks @cody-dot-js! - Icons: export AutoThemeIcon and ThemeIcon
Patch Changes
-
#631
2d18988Thanks @dependabot! - Update dependencies -
#609
fc8b793Thanks @dependabot! - Update dependencies
0.30.0
Minor Changes
- #620
c76d1bdThanks @aaronshekey! - AddAlertDismissIconButtontoAlertcomponent
Patch Changes
- #617
c72c6e1Thanks @aaronshekey! - Table edge case refinements and a sans serif font on tooltips by default
0.29.0
Minor Changes
- #603
264dc1bThanks @dependabot! - Update dependencies; use named *Icon imports from phosphor
0.28.1
Patch Changes
- #595
26cdffaThanks @cody-dot-js! - Revert phosphor update
0.28.0
Minor Changes
- #591
2cf4f4cThanks @cody-dot-js! - Update phosphor icons to new suffix Icon imports
0.27.4
Patch Changes
- #589
2eb2764Thanks @cody-dot-js! - RadioButton: don't highlight border on hover when checked AND disabled
0.27.3
Patch Changes
-
#559
9da7027Thanks @cody-dot-js! - tsconfig: improve tsconfigs and remove unused imports/vars -
#551
fceb2efThanks @cody-dot-js! - Table: improve component design and styling -
#576
ce359c0Thanks @dependabot! - update dependencies -
#568
3d9c378Thanks @dependabot! - update dependencies
0.27.2
Patch Changes
-
#544
cc043a7Thanks @cody-dot-js! - DataTable: fix react key errors in DataTableHead and DataTableRow -
#546
44cc9b9Thanks @cody-dot-js! - DataTable: fix hover styles, add DataTableActionCell -
#546
44cc9b9Thanks @cody-dot-js! - Tailwind Preset: fix content glob, should point to dist folder now
0.27.1
Patch Changes
-
#538
1c2f1afThanks @cody-dot-js! - DataTable: add most of the remaining core components -
#536
585624aThanks @cody-dot-js! - DataTable: export both DataTableHeaderSortButton and DataTableHeader, decouple behavior to make it fully customizable
0.27.0
Minor Changes
- #533
8211d3bThanks @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
-
#533
8211d3bThanks @cody-dot-js! - DataTable: add tests to helper methods, render example on docs site -
#533
8211d3bThanks @cody-dot-js! - Toast: change class from .mantle-prompt to .overlay-prompt, improve compat w/ legacy ui components -
#533
8211d3bThanks @cody-dot-js! - Table: fix/improve styling -
#533
8211d3bThanks @cody-dot-js! - Update dependencies
0.26.0
Minor Changes
-
#525
bf41174Thanks @cody-dot-js! - Add custom mantle icons exports (Sort, TrafficPolicyFile) and sorting helper functions, types -
#527
f702d8aThanks @cody-dot-js! - DataTable: add DataTableHeader component, which supports column sorting by default (if enabled on the column) -
#527
f702d8aThanks @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
-
#524
38ac3dbThanks @dependabot! - Update dependencies -
#527
f702d8aThanks @cody-dot-js! - Table: Improve Table component docs
0.25.2
Patch Changes
-
#515
eb03234Thanks @cody-dot-js! - PopoverContent: add preferredWidth prop, use that instead of explicit width -
#515
eb03234Thanks @cody-dot-js! - Calendar: add min-w-min to months container (prevent overflow in nav)
0.25.1
Patch Changes
-
#513
052f571Thanks @cody-dot-js! - AlertDialogContent, DialogContent: add preferredWidth prop -
#510
7dcc1e0Thanks @dependabot! - Update dependencies
0.25.0
Minor Changes
- #501
a669615Thanks @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
-
#500
275f8d1Thanks @dependabot! - update dependencies -
#501
a669615Thanks @cody-dot-js! - CodeBlock: improve documentation and intellisense
0.24.1
Patch Changes
-
#497
b5526a0Thanks @cody-dot-js! - AlertDialog: improve intellisense and docs, support asChild -
#496
8d88570Thanks @cody-dot-js! - Flag: improve intellisense -
#496
8d88570Thanks @cody-dot-js! - Icon: update docs and intellisense -
#496
8d88570Thanks @cody-dot-js! - Label: update docs and intellisense -
#496
8d88570Thanks @cody-dot-js! - MediaObject: update docs and intellisense, add asChild support -
#494
c4961acThanks @cody-dot-js! - Card: improve docs and intellisense, support asChild on all sub-components -
#496
8d88570Thanks @cody-dot-js! - Badge: improve intellisense -
#493
410bc33Thanks @melanieseltzer! - Combobox: fix intellisense onComboboxItemValue -
#496
8d88570Thanks @cody-dot-js! - Skeleton: update docs and intellisense, add asChild support
0.24.0
Minor Changes
- #491
97538d9Thanks @cody-dot-js! - Anchor: add support for icons and their placement, improve docs and intellisense
0.23.2
Patch Changes
- #489
1c47edcThanks @cody-dot-js! - CodeBlock: fix unescaped inner html on initial render
0.23.1
Patch Changes
-
#486
671867cThanks @cody-dot-js! - Switch: improve intellisense and docs, fix readOnly prop -
#483
247bbc4Thanks @cody-dot-js! - Separator, ProgressDonut: improve docs and intellisense -
#485
13d21f3Thanks @cody-dot-js! - Checkbox: improve intellisense and docs
0.23.0
Minor Changes
-
#478
1b8857bThanks @cody-dot-js! - Add SandboxedOnClick component -
#477
de42d20Thanks @cody-dot-js! - SheetContent: add preferredWidth prop, remove side: "top" and "bottom" options
Patch Changes
-
#469
d879af8Thanks @dependabot! - Update dependencies, add dependabot config, clean up scripts -
#479
e775e11Thanks @cody-dot-js! - PopoverContent: prevent click events from bubbling out of the PopoverContent container -
#480
c87c906Thanks @cody-dot-js! - TextArea: improve docs and intellisense
0.22.2
Patch Changes
- #467
1556c80Thanks @dependabot! - Update dependencies
0.22.1
Patch Changes
-
#464
b55338eThanks @dependabot! - Update dependencies -
#466
a708b7fThanks @cody-dot-js! - Sheet: improve inline intellisense and docsite documentation
0.22.0
Minor Changes
- #462
2e9f116Thanks @cody-dot-js! - Checkbox: remove zodCheckbox export
Patch Changes
- #460
e62f0efThanks @cody-dot-js! - Select: improve component docs, add inline intellisense examples w/ links, make content width default to "trigger"
0.21.5
Patch Changes
- #458
494430cThanks @dependabot! - update prism to fix security vulnerability
0.21.4
Patch Changes
- #456
2ed9953Thanks @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
- #454
2ddbdabThanks @cody-dot-js! - ProgressDonut: properly fix the render AND hydration issue
0.21.2
Patch Changes
- #452
f76dc0eThanks @cody-dot-js! - ProgressDonut: fix hydration error, add examples in intellisense
0.21.1
Patch Changes
- #450
00de431Thanks @cody-dot-js! - CodeBlock: fix alignment of absolutely positioned copy button
0.21.0
Minor Changes
- #428
944e9a6Thanks @cody-dot-js! - Add combobox component
Patch Changes
- #447
8d47922Thanks @cody-dot-js! - Flags: add GB alias for UK flag
0.20.2
Patch Changes
- #444
63ce798Thanks @cody-dot-js! - Flags: rename Flags type to CountryCode, export countryCodes list and isCountryCode type predicate fn
0.20.1
Patch Changes
-
#443
46087c5Thanks @cody-dot-js! - Flag: add border radius and inset transparent black border -
#441
2867bdfThanks @cody-dot-js! - Prep for react 19: swap react-19-deprecated ElementRef type for ComponentRef type
0.20.0
Minor Changes
- #439
1523118Thanks @cody-dot-js! - Add Flag component
0.19.17
Patch Changes
- #435
0aac02aThanks @cody-dot-js! - AlertDialog: actually export AlertDialogClose
0.19.16
Patch Changes
- #433
4744706Thanks @cody-dot-js! - AlertDialog: export AlertDialogClose
0.19.15
Patch Changes
- #431
7358307Thanks @cody-dot-js! - DropdownMenuRadioItem: only take up space for the checkmark when an item is checked
0.19.14
Patch Changes
-
#430
ec4e2e4Thanks @cody-dot-js! - button: fix underline on group-hover bug of bug -
#427
7b9ef0bThanks @cody-dot-js! - Update dependencies -
#425
0dad7dfThanks @dependabot! - update dependencies
0.19.13
Patch Changes
- #422
e87acd6Thanks @cody-dot-js! - types: export parseBooleanish
0.19.12
Patch Changes
- #419
d78b2ceThanks @melanieseltzer! - Make theclassNamearg onanchorClassNamesoptional
0.19.11
Patch Changes
- #417
298b175Thanks @cody-dot-js! - CodeBlock: adjust styles, add defensive css (for docs)
0.19.10
Patch Changes
- #415
4275988Thanks @cody-dot-js! - mantle.css: improve matching for css vars on root
0.19.9
Patch Changes
- #413
d42bb6fThanks @cody-dot-js! - SelectItem: improve styling to fix hover bg color on selected items in docs use case
0.19.8
Patch Changes
- #411
88727bdThanks @cody-dot-js! - CodeBlock: add CodeBlockIcon component and fix some styling
0.19.7
Patch Changes
-
#408
0868adbThanks @melanieseltzer! - Revamp the readme and contributing guide -
#410
de54a95Thanks @cody-dot-js! - add fade-in keyframes to tailwind preset
0.19.6
Patch Changes
1d83e1dThanks @cody-dot-js! - Slack post will work this time for real i promise u 🦐
0.19.5
Patch Changes
595e6c1Thanks @cody-dot-js! - Fixing the post to slack webhook on mantle release
0.19.4
Patch Changes
cf2d8c2Thanks @cody-dot-js! - Debugging changesets release
0.19.3
Patch Changes
-
#399
cbeec74Thanks @cody-dot-js! - Update patch dependencies of radix component packages https://github.com/ngrok/mantle/pull/396 Improve code quality (suggestions from trialing biomejs) https://github.com/ngrok/mantle/pull/398For historical release notes prior to
v0.19.3, please consult https://github.com/ngrok/mantle/releases