Drilldown
A stack of views inside one surface, such as Settings → Account → Email in a dialog. A Drilldown.Trigger pushes a view in place, Drilldown.Back pops it, and the overlay around it stays open. Drilldown goes inside a Dialog, Drawer, BottomSheet or Popover, or inline on a page.
On this page
Drilldown.Root renders no element, so it can wrap both the overlay's header and its body. Drilldown.Back goes before the title and renders nothing at the first view. Drilldown.Label prints the shown view's label, so the title, and the dialog's name with it, follows the view. Drilldown.Viewport holds the views, and each Drilldown.View is a group named by its label.
<Dialog.Root open={open} onOpenChange={setOpen}>
<Drilldown.Root defaultValue={['settings']}>
<Dialog.Header>
<Drilldown.Back />
<Dialog.Title>
<Drilldown.Label />
</Dialog.Title>
<Dialog.Close />
</Dialog.Header>
<Dialog.Body>
<Drilldown.Viewport>
<Drilldown.View value="settings" label="Settings">
<Drilldown.Trigger value="account">Account</Drilldown.Trigger>
<Drilldown.Trigger value="notifications">Notifications</Drilldown.Trigger>
</Drilldown.View>
<Drilldown.View value="account" label="Account">…</Drilldown.View>
<Drilldown.View value="notifications" label="Notifications">…</Drilldown.View>
</Drilldown.Viewport>
</Dialog.Body>
</Drilldown.Root>
</Dialog.Root>
The stack
The value is the stack of view values, the first view first and the shown view last. Drilldown.Root takes value with onValueChange, or defaultValue. onValueChange gets the next stack and a reason: push from a trigger, back from Drilldown.Back, escape from Escape or the Android back gesture, and reset when an uncontrolled stack starts over. A stack rather than a fixed tree lets one view be reached from two places, and lets a controlled value jump several views at once, such as ['settings', 'account', 'email'] for a deep link.
A view is mounted while its value is in the stack. Views lower in the stack stay mounted and hidden, so going back keeps their form input. A view that leaves the stack unmounts once its slide ends. An uncontrolled stack goes back to defaultValue each time the Dialog, Drawer, BottomSheet or Popover around it finishes closing, after its exit motion, and onValueChange gets reason reset. A controlled stack stays as you left it, and so does a surface opened again during its exit. A Drilldown you unmount, or hide with <Activity>, before the surface finishes closing gets no reset, and neither does one whose surface you unmount during its exit. A surface you hide with <Activity> during its exit sends no reset while it stays hidden; it sends one when it shows again and finishes closing.
Drilldown.Trigger is a Button and takes Button's props, so it can be a primary Next button, a ghost row with a chevron, or a link through render. To push after async work, such as validating a step first, control value and set the stack when the work is done; Back still returns focus to the trigger. Only the next change of the stack can count as the trigger's push: if it shows that view, Back returns focus to the trigger, so a push you turn down and then make yourself returns focus there too. Any other change first, such as showing another view, ends the push, and Back then focuses the view's first tab stop.
import { ChevronRight } from 'lucide-react';
<Drilldown.Trigger value="brand" variant="ghost">
Brand
<Button.Icon>
<ChevronRight />
</Button.Icon>
</Drilldown.Trigger>
Escape and the back gesture
With escape="back", the default, Escape goes back one view and closes the overlay only at the first view, the way Escape closes the innermost layer first. In a Popover, Escape goes back before the popover closes; in a Drilldown inside another one's view, the inner one goes back first. The Android back gesture and button reach a modal <dialog> as a close request with no key, and Drilldown answers it the same way, so a deep stack isn't thrown away. Chrome lets a page cancel only some close requests in a row and closes the dialog on the rest; Drilldown still goes back on those, and the dialog shows again. A menu or popover open in the overlay, beside the views or inside one, takes Escape and the back gesture before Drilldown goes back; an open tooltip takes Escape only. Both go to Drilldown only while focus is in the dialog or popover around it, or lost to the page while that surface is in front, so with focus outside a Popover that holds a Drilldown, Escape and the back gesture close the popover. In a dialog with dismissOnEscape={false}, Escape still goes back while there is a view to go back to. escape="close" leaves Escape to the overlay. Escape that a control in the view already took, such as a combobox closing its list, keys that belong to an IME composition, and a Drilldown that isn't on screen, such as one in a closed popover or a hidden tab panel, don't go back.
In a BottomSheet, the sheet keeps its snap point and the grabber still drags the sheet. There is no edge swipe to go back.
Motion
A pushed view slides in from the inline end while the one below shifts toward the inline start and fades; going back runs the reverse. Start and end follow text direction, so in right-to-left text a pushed view comes in from the left. The viewport animates its height between views, so a dialog that sizes to its content doesn't jump. The height holds at the new view's until every view finishes its slide, also when CSS makes the slide longer. On a pop that brings back a scroll offset the height on screen can't reach, the height follows the taller of the two views until the slide ends, then fits the view, so the offset isn't cut short. The slide reads drilldown.motion.duration and drilldown.motion.easing.enter and exit, and the height falls back to their defaults when it can't read them, such as without Todae's stylesheet. While a view moves, data-direction on the viewport is forward or back, and the moving view has data-entering or data-leaving set to the direction it moves in. The views slide with CSS transitions, so a view that a push or pop overtakes during its slide goes on from where it is, and the viewport's height goes on from the height on screen unless the change is such a pop. A leaving view stays inert until its own transitions finish, so CSS that changes their length keeps it in step. With reduced motion there is no slide.
Accessible composition
Todae handles: Each Drilldown.View is a role="group" named by its label, and Drilldown.Label puts that label in the overlay's title, so the dialog's name follows the view. When a view shows with nothing remembered for it, on a push or the first time a controlled change shows it, focus goes to its initialFocusRef if that can take focus, else its first tab stop, else the view itself. On a pop to a view that showed before, focus goes back to the trigger that pushed the view above it, and when that trigger is gone or disabled, to the same initialFocusRef, first tab stop or view, in that order. Focus moves only when it was in the views, on Back, on the overlay's own element or nowhere, so a Next button of your own outside the views that sets a controlled value keeps it. Focus moves without scrolling. On a push or a controlled change forward, the overlay's scroll container, or the page inline, scrolls back to the top of the viewport if it was past it. A view that showed when a change covered it keeps its scroll offset and the trigger that pushed, and gets both back on a pop to it. A view a controlled jump went past never showed, so it has nothing to get back: a pop to it starts at the top of the viewport, and focus goes where it goes in a newly pushed view. Views lower in the stack are hidden, and a view sliding out is inert and aria-hidden, so neither Tab nor a screen reader lands on it. Drilldown.Back is named "Back to" and the label of the view below, from the backTo string of StringsProvider, and its glyph is the back glyph of GlyphsProvider, mirrored in right-to-left text.
You supply: A label for every view, short enough for a title. A Drilldown.Label inside the overlay's title, or a title of your own that changes with the view. A trigger whose text says where it goes; a row with only an icon needs aria-label.
Watch for: Two views with the same value, or a stack that holds a value twice: a trigger won't push a value already in the stack. A view whose content changes while it is lower in the stack: it stays mounted, so effects in it keep running. Long work started from a trigger: the view slides in at once, so show progress in the new view, or control value and push when the work is done. Inline on a server-rendered page: Drilldown.Label and Back's name come from the views once they mount, so they are empty in the server's HTML and until the first layout effect.
When to use
When: A few levels of settings, filters or details inside one overlay or panel, where each step narrows the one before it and the person comes back the same way, such as Settings → Account → Email or Filters → Brand.
When not: Sibling sections of the same object, one at a time → Tabs. Moving to another page with its own URL → links and your router. A list of actions → Menu. A step-by-step flow whose steps the person needs to see at once → Steps beside the form.
Props
| Name | Type | Description |
|---|---|---|
value | readonly string[] | Controlled stack of view values, the first view first and the shown view last. Pass with onValueChange. |
defaultValue | readonly string[] | Initial stack when uncontrolled. The stack goes back to it each time an enclosing Dialog, Drawer, BottomSheet or Popover finishes closing, and onValueChange fires with reason reset. |
onValueChange | (value: readonly string[], reason: DrilldownChangeReason) => void | Fires with the next stack. reason is push (Drilldown.Trigger), back (Drilldown.Back), escape (Escape, or the Android back gesture in a modal) or reset (an uncontrolled stack going back to defaultValue as its overlay finishes closing). |
escape | "back" | "close" | back: Escape goes back one view, and closes an enclosing overlay only at the first view. close: Escape is left to the overlay. Default: "back" |
children | React.ReactNode | Drilldown.Viewport, Drilldown.Back and Drilldown.Label, anywhere inside. |
| Name | Type | Description |
|---|---|---|
children | React.ReactNode | Drilldown.View elements. |
| Name | Type | Description |
|---|---|---|
value Required | string | The value Drilldown.Trigger pushes and the stack holds. |
label Required | string | Names the view's group, Drilldown.Label while it shows, and Drilldown.Back from the view above it. |
initialFocusRef | RefObject<HTMLElement | null> | Where focus goes when the view shows with no trigger to return to: on a push, the first time a controlled change shows it, or on a pop once the trigger that pushed the view above is gone. Without it, or when it can't take focus, the first tab stop in the view, or the view itself. |
children | React.ReactNode | The view's content. |
| Name | Type | Description |
|---|---|---|
value Required | string | The Drilldown.View to push. Focus returns to this trigger when that view is popped. |
variant | ButtonVariant | Button variant. Default: "primary" |
render | (props: ButtonRenderProps) => ReactElement | Draws the trigger on another element, such as a row or a link, as Button.Root does. |
onClick | React.MouseEventHandler<HTMLElement> | Runs before the push. Call event.preventDefault() to stay on this view. |
children | React.ReactNode | Trigger content. |
| Name | Type | Description |
|---|---|---|
aria-label | string | Accessible name. Text of two or more letters or digits names the button by itself. Otherwise the backTo string from StringsProvider names it with the label of the view below, or the back string when that view has no label. Default: "Back to {label}" when its text, not counting aria-hidden or SVG parts, has fewer than two letters or digits |
onClick | React.MouseEventHandler<HTMLButtonElement> | Runs before going back. Call event.preventDefault() to stay. |
children | React.ReactNode | Button content. Without it, the back glyph from GlyphsProvider, or Todae's chevron pointing to the start of the line. |
| Name | Type | Description |
|---|