Drill menu
A nested menu that drills down in place: the item you choose rises into the title, and the back arrow brings it home.
drill-menu/drill-menu.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Settings or filters nested two to four levels deep, in a space too small for a sidebar and a panel side by side.
- A popover or a sheet whose size is fixed: each level replaces the last in place, and the card only grows to the longest one.
- Trees whose sections are worth a name of their own: the item you open becomes the title, so you always see where you are.
- On/off options at the end of a path, switched in place with
onToggleand no Save button.
When not to use
- A flat list of ten options or fewer. Show them all in one menu or a group of switches.
- Comparing or changing options from different sections at once: only one level is ever on screen. Use a settings page with sections.
- Choosing one value out of many, like a country. Use a select or a searchable combobox.
- Trees more than four levels deep, or ones people search more than browse. Add a search field, or use a command palette.
- Actions that run when picked (Rename, Delete). Its leaves are switches; use a plain menu for commands.
You can add this component with the shadcn CLI or by hand:
Into components/drill-menu of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/drill-menu.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { DrillMenu } from './drill-menu';import type { MenuItem } from './menu';// Items with children open a level of their own; the others switch on and off.const VIEW: MenuItem[] = [ { id: 'sort', label: 'Sort', children: [ { id: 'sort-folders', label: 'Folders first', on: true }, { id: 'sort-recent', label: 'Recent on top' }, ], }, { id: 'hidden', label: 'Show hidden files' },];export function ViewMenu() { return ( <DrillMenu title="View" items={VIEW} autoplay={false} onToggle={(item, on) => console.log(item.label, on)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
Below are variations of this component, each live, with its code.
Long top level
A map’s layers in six rows, switches beside sections and most of them on; the card shrinks to fit a section as it opens.
Deep link
A home’s controls opened four levels deep, on the kitchen’s lights, through defaultPath; the back arrow climbs out a level at a time.
Filter count
A shop’s filters, whose title counts the ones switched on: onToggle keeps the count.
A settings menu three levels deep. Choose an item that holds a level of its own and it slides up into the title’s place, greying as it goes, while the list around it blurs away and its children slide in below. The back arrow sinks the title back into its list as the items around it return, and the card’s height follows each level on the same spring.
- Each level on screen is a layer. A change of level adds the new one and keeps the old one laid over it, inert, just long enough to animate it away.
- The item you choose and the title it becomes are two elements, one in each layer. FLIP plays the new title from where the item is on screen while its colour turns from an item’s to a title’s. Going back runs the same in reverse, and a quick reversal starts from wherever the title is mid-flight.
- The old title rises out of sight and the old list clears with a little blur before the new items slide up one after another. Going back, the parent’s title comes down from above and its items return around the one coming home.
- The motion is a damped spring, simulated once and sampled into a CSS linear() easing, then played by the Web Animations API. The card’s height follows the same spring.
- Options are switches: a check pops into the gutter under the back arrow.
- Every colour is a light-dark() token on the root, so the menu follows the page’s color-scheme, or the colorScheme prop; the one colour script touches, the item’s as it rises into the title, is read from the computed style as it moves.
| Prop | Type | Default | Description |
|---|---|---|---|
items | MenuItem[] | SETTINGS | The top level’s items. Each has an id (unique in the tree), a label, and either children (a level of its own) or on (an option that starts switched on). |
title | string | 'Settings' | The top level’s title. |
defaultPath | string[] | — | The ids of the items leading to the level open at first, in order from the top; the top level when left out. |
defaultPointer | { target: string; pressed?: boolean } | — | The item drawn under the pointer at first (an item’s id in the open level, or 'back'), pressed or not; the reader’s next change of level clears it. Setting it turns the demo off. |
onToggle | (item: MenuItem, on: boolean) => void | — | Called when an option is switched on or off. |
autoplay | boolean | true | Browses the menu with a demo cursor while on screen and untouched. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type SVGProps,} from 'react';import { DemoCursor, glideTo, hideCursor, pressWith } from './demo-cursor';import { SETTINGS, type MenuItem } from './menu';import { spring, timing } from './spring';import { useAutoplay } from './use-autoplay';import styles from './drill-menu.module.css';/* * Drill menu: a nested menu that drills down in place. Each level shows a back arrow and its * title over a list. Choose an item that opens a level of its own and it rises into the title's * place while the list around it fades away and its children slide in; go back and the title/* Drill menu (drill-menu.tsx): a light stage with the menu in a white card, or at night a near-black stage with the menu in a dark grey one. The card hangs from the top of a slot as tall as the menu's longest level, and the slot sits in the middle of the stage, so the title stays put while the card grows and shrinks below it. Sizes are in em: the menu's font-size follows the component's width (a container), from 15px on a phone to 18px. */.root { /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root inherits from the page, or the one the colorScheme prop sets (below). */ /* The stage, then the card and the keys a step up. */ --surface: #eaeaed; --surface-2: #fff; /* Items; titles and the back arrow; chevrons; the keys' line on the stage. */ --ink: #1d1d1f; --ink-muted: #6e6e73; --ink-faint: #8e8e93; --stage-ink: #636366; /* The stage's and the keys' edges, the card's, and the stage's dots. */ --line: rgb(0 0 0 / 0.06); --card-line: rgb(0 0 0 / 0.05); --dots: rgb(0 0 0 / 0.05); /* An item or the back row under the pointer, then pressed. */ --hover: #f2f2f5; --press: #e8e8ed;/* * The drill menu's content: a tree of items. An item with children opens a level of its own; an * item without is an option, switched on and off. */export type MenuItem = { /** Unique across the whole tree. */ id: string; label: string; /** The level this item opens. */ children?: MenuItem[]; /** An option that starts switched on. */ on?: boolean;};export const SETTINGS: MenuItem[] = [ { id: 'gestures', label: 'Gestures', children: [ { id: 'swipe', label: 'Swipe', children: [/* * Springs for the Web Animations API. A damped spring released from 0 towards 1 is simulated * once, then sampled into a linear() easing: played over the time it takes to settle, it * overshoots and settles like the spring, and the browser runs it like any other curve. */export type Spring = { /** The spring's motion, as a CSS easing. */ easing: string; /** How long it takes to settle, in ms. */ duration: number;};/** For browsers without linear(): a curve that overshoots a touch, then settles. */const FALLBACK = 'cubic-bezier(0.25, 1.15, 0.4, 1)';/** * A spring of unit mass: `stiffness` pulls it towards its target, `damping` holds it back. The * lower the damping, the more it overshoots. */export function spring(stiffness: number, damping: number): Spring { const step = 1 / 1000; const positions = [0]; let position = 0;'use client';import type { Ref } from 'react';import styles from './demo-cursor.module.css';/* * The demo's pointer: while the demo plays on its own, an arrow glides to what it is about to * press and dips as it presses, so the demo reads like someone using the component. It sits in * the stage (its offset parent). Each glide is a CSS transition, the two axes on different * easings so that the path bends a little: nothing runs between moves. */const ARROW = 'M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z';export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) { return ( <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true"> <span className={styles.vertical}> <span className={styles.ring} /> <svg className={styles.arrow} viewBox="0 0 20 20"> {/* Its shadow: the arrow again, offset and faint (a CSS filter would cost a large GPU surface while the cursor moves). */} <path className={styles.shade} d={ARROW} transform="translate(0.3 1.1)" /> <path className={styles.body} d={ARROW} />/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in the stage. The outer box carries the horizontal move and the inner one the vertical move, eased differently, which bends the path into a gentle arc. It follows the color-scheme it sits in: a dark arrow edged in white on light, a light one edged in black on dark. */.cursor { /* The arrow and its edge, its shadow and the shadow's edge, the ring where it presses. */ --pointer: #1d1d1f; --pointer-edge: #fff; --pointer-shadow: rgb(0 0 0 / 0.2); --pointer-shadow-edge: rgb(0 0 0 / 0.1); --pointer-ring: rgb(0 0 0 / 0.14); --x: 0px; --y: 0px; --move: 0ms; position: absolute; top: 0; left: 0; z-index: 10; pointer-events: none; opacity: 0; transform: translateX(var(--x)); transition: opacity 0.25s ease,'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- It is a menu: branches are menu items with aria-haspopup, and options are menuitemcheckbox with aria-checked. The back button names both levels (“Gestures, back to Settings”).
- The up and down arrows (and Home, End) move; Enter, Space or the right arrow open; Escape, the left arrow or Backspace go back. Focus moves to the first item of a new level, and back to the item you came from.
- Level changes (“Swipe, 4 items”) and switched options are announced in a polite live region. The second click of a double click is ignored, so it never drills two levels.
- Under reduced motion levels switch at once, with no movement or blur, and there is no demo.