A vertically stacked set of disclosure sections, styled after the shadcn / ngrok.com accordion.
Its defining feature: collapsed content is never removed from the DOM. Closed sections keep their content with
hidden="until-found" (which applies content-visibility: hidden rather than unmounting it), so the browser's
find-in-page (⌘F / Ctrl+F) can find text inside a closed section and automatically expand it.
The beforematch event — fired right before the browser reveals a match — syncs that reveal back into the accordion's
state. It's built on plain <div> and <button> elements (matching native <details>/<summary> accessibility:
the item is a role="group" and the trigger a <button aria-expanded>), so a section header can be any layout.
--domain flag.1import { Accordion } from "@ngrok/mantle/accordion";2 3<Accordion.Root type="single" defaultValue="dns">4 <Accordion.Item value="dns">5 <Accordion.Trigger>6 How do I point my custom domain at an ngrok endpoint?7 <Accordion.TriggerIcon />8 </Accordion.Trigger>9 <Accordion.Content>Add a CNAME record at your DNS provider…</Accordion.Content>10 </Accordion.Item>11</Accordion.Root>;note
Tabs instead.Use type="multiple" to let any number of sections be open at once. Pass defaultValue (uncontrolled) or value +
onValueChange (controlled) as a string[].
1<Accordion.Root type="multiple" defaultValue={["one"]}>2 {/* …items… */}3</Accordion.Root>Drive the open state yourself with value + onValueChange when another control needs to open or collapse sections.
In type="single" the value is a string (use "" for "nothing open"); in type="multiple" it's a string[].
Open section: payments
1import { Accordion } from "@ngrok/mantle/accordion";2import { useState } from "react";3 4function Settings() {5 const [value, setValue] = useState("payments");6 7 return (8 <Accordion.Root type="single" value={value} onValueChange={setValue}>9 <Accordion.Item value="payments">10 <Accordion.Trigger>11 Payments12 <Accordion.TriggerIcon />13 </Accordion.Trigger>14 <Accordion.Content>Connect a processor and manage payouts.</Accordion.Content>15 </Accordion.Item>16 {/* …items… */}17 </Accordion.Root>18 );19}The dashboard's Traffic Policy GUI puts a monospace label, a count badge, and an always-visible Add Rule action in
each header. Because the Item is a plain <div> and the Trigger is a real <button>, the header is just a flex
row holding the Trigger, a divider, and the action button as siblings — a clean composition with no overlay and
no absolute positioning. The Trigger toggles its section; the Add Rule button is an independent sibling that
never toggles it; and the Content spans full-width below. Override the defaults (space-y-* on Root, border-b-0
on Item, w-auto on Trigger so it sizes to its label) for the spaced-card look.
This phase does not have any rules defined
1// The header is a plain flex row: the Trigger button, a divider, and the action2// button are siblings — no overlay, no absolute positioning. The Add Rule button3// is outside the Trigger, so it never toggles the section; Content is full-width.4<Accordion.Root type="multiple" className="space-y-2.5">5 <Accordion.Item value="on_tcp_connect" className="border-b-0">6 <div className="mx-4 flex items-center gap-2">7 <Accordion.Trigger className="w-auto gap-1.5">8 <span className="font-mono text-sm font-medium">on_tcp_connect</span>9 <Badge appearance="muted" color="neutral" className="rounded-full">10 311 </Badge>12 <Accordion.TriggerIcon />13 </Accordion.Trigger>14 <Separator orientation="horizontal" className="flex-1" />15 <Button type="button" appearance="link" icon={<PlusIcon />}>16 Add Rule17 </Button>18 </div>19 <Accordion.Content>20 <Card.Root>21 <Card.Body>Proident irure consequat Lorem incididunt ullamco.</Card.Body>22 </Card.Root>23 </Accordion.Content>24 </Accordion.Item>25</Accordion.Root>Pass svg to Accordion.TriggerIcon to replace the default caret. The indicator still rotates 180° on open; override
its className to change that — here a right caret rotates 90° into a downward one for a file-tree feel.
1import { CaretRightIcon } from "@phosphor-icons/react/CaretRight";2 3<Accordion.Trigger>4 Request logs5 <Accordion.TriggerIcon6 svg={<CaretRightIcon weight="bold" />}7 className="group-data-[state=open]:rotate-90"8 />9</Accordion.Trigger>;The accessibility mirrors a native <details>/<summary>: each Accordion.Item is a role="group", and each
Accordion.Trigger is a real <button> whose aria-expanded reflects its section's open state. Because the trigger
is a genuine button, keyboard support is the browser's own — there is no custom key handling to get wrong:
Collapsed content stays in the DOM but is hidden via content-visibility, so it leaves the accessibility tree exactly
like a closed <details> — and the browser's find-in-page can still match and reveal it. The open/close slide respects
prefers-reduced-motion, and the component is SSR-safe (the hidden="until-found" enhancement only activates in
browsers that support it).
Compose the parts of an Accordion together to build your own:
Accordion.Root└── Accordion.Item ├── Accordion.Trigger │ └── Accordion.TriggerIcon └── Accordion.ContentThe Accordion owns its open/closed state and stays in sync whether a section is opened by a click, by keyboard, or by
the browser's find-in-page.
Owns the accordion state and renders a wrapping <div> (or your own element via asChild). The type prop is a
discriminated union that shapes the open value:
type="single" — at most one section open at a time. value / defaultValue are a string (use "" for none),
and onValueChange receives the newly open value.type="multiple" — any number open. value / defaultValue are a string[], and onValueChange receives the new
list.Provide value + onValueChange for controlled usage, or defaultValue for uncontrolled usage. Accepts all <div>
props, plus:
A single collapsible section, rendered as a <div role="group"> — the role a native <details> carries. Its open
state is derived from Root. Because the item is a plain <div>, its header can be any layout, including a flex row
with the Trigger beside a separate, non-toggling action button. Accepts all <div> props, plus:
The clickable header that toggles its section, rendered as a <button aria-expanded> — the disclosure semantics a
native <summary> provides, via a real button so the role is consistent across browsers. Defaults to full-width with
the TriggerIcon pushed to the trailing edge; set w-auto when placing it in a custom header row. A consumer onClick
runs first and the section still toggles afterward (calling preventDefault() does not stop the toggle). Accepts all
<button> props except type, which is always "button".
The indicator icon. Defaults to a downward caret that rotates 180° when its section opens. Accepts all
Icon props (notably className and style), plus:
The collapsible body, rendered as a <div> that is always present in the DOM. When collapsed (in supporting browsers)
it carries hidden="until-found" so its text stays discoverable by find-in-page, and a beforematch listener opens
the section when the browser reveals a match. It is flow content, so it may contain anything — including
interactive elements (buttons, links, inputs) or a nested Accordion. Accepts all <div> props.