Overlays & Disclosure
Modal, Popover, Dropdown, Tabs and Accordion — the anchored, layered and expand/collapse primitives every Luminaux feature composes on, all token-based and dismissable with Escape + click-outside.
Overview
These are Luminaux's overlay and disclosure primitives — the building blocks for anything that
floats above the page (dialogs, menus, popovers) or expands/collapses in place (tabs, accordions). They
live in src/components/ui/ and are re-exported from the barrel src/components/ui/index.ts, so a page
imports them all from @/components/ui.
Two shared behaviors run through the whole set:
- Escape + click-outside dismissal. Popovers and dropdowns use the
useDismisshook (src/hooks/useDismiss.ts); modals wire their own Escape + backdrop-click listeners inSheetModal. Users can always close an overlay without reaching for a specific button. - Portaling to escape overflow clipping.
PopoverandDropdownaccept aportalprop that renders the panel into adocument.bodyportal (fixed-positioned), so a menu opened inside a scrollable/overflow-hiddenancestor (a kanban column, a data-grid cell, a panel header) is never clipped. The portaled panel is viewport-aware: it measures itself, flips above/below when the preferred side would overflow, clamps horizontally on-screen, and repositions on scroll/resize so it stays anchored to the trigger.Modal/SheetModalalways portal to the body.
All motion pulls from the shared tokens in src/lib/motion.ts (popUp, spring) and honors global
reduced-motion (see Animation & effects). All colors are semantic tokens
(see Design tokens & dark mode) — no hardcoded hex.
Architecture & files
| File | Responsibility |
|---|---|
src/components/ui/Modal.tsx | Titled dialog (title / description / footer, size sm→full) built on SheetModal. |
src/components/motion/SheetModal.tsx | The responsive shell — centered dialog on desktop, drag-to-dismiss bottom sheet on mobile. Portals to document.body; handles Escape + backdrop click. |
src/components/ui/Popover.tsx | Anchored floating panel. cloneElements the trigger; dismiss via useDismiss; optional viewport-aware document.body portal. |
src/components/ui/Dropdown.tsx | Menu built on Popover — Dropdown + DropdownItem / DropdownSub / DropdownSeparator / DropdownLabel. Items auto-close via a CloseContext. |
src/components/ui/Tabs.tsx | Tabs / TabList / Tab / TabPanel. Four visual variants; sliding indicator via a per-instance layoutId; roving-tabindex keyboard nav; controlled or uncontrolled. |
src/components/ui/Accordion.tsx | Accordion / AccordionItem. type single | multiple, four variants, chevron/plus indicators; bodies animate via Collapse; controlled or uncontrolled. |
src/hooks/useDismiss.ts | Shared close-on-outside-click + Escape hook returning a ref to place on the wrapper. |
Demo pages (wired via appRoutes in src/routes.tsx): src/pages/ui/ModalsPage.tsx (/ui/modals),
DropdownsPage.tsx (/ui/dropdowns — includes a 3-level DropdownSub "Move to…" demo), TabsPage.tsx
(/ui/tabs), AccordionPage.tsx (/ui/accordion).
Key dependencies: motion/react (free core only), lucide-react icons, cn() from src/lib/cn.ts.
How dismissal works (useDismiss)
export function useDismiss<T extends HTMLElement>(
open: boolean,
onClose: () => void,
extraRefs: RefObject<HTMLElement | null>[] = [],
) { /* … */ }useDismiss(open, onClose) returns a ref you place on the wrapping element. While open, a mousedown
outside that element (or an Escape keypress) calls onClose. The optional extraRefs array lets a
portaled panel — rendered outside the wrapper in the DOM — still count as "inside", so clicking it
doesn't dismiss. Popover passes its portaled panel ref through extraRefs for exactly this reason.
Usage
import {
Modal,
Popover,
Dropdown, DropdownItem, DropdownSeparator, DropdownLabel,
Tabs, TabList, Tab, TabPanel,
Accordion, AccordionItem,
} from '@/components/ui'Overlays that show/hide (Modal) are controlled by an open boolean you own; anchored overlays
(Popover, Dropdown) manage their own open state internally and expose a close callback to their
children.
API / Props
Modal
A titled dialog rendered over SheetModal. Backdrop click and Escape close it.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Whether the dialog is shown (you control this). |
onClose | () => void | — | Called on backdrop click, Escape, or the × button. |
title | ReactNode | — | Heading; wired to aria-labelledby. |
description | ReactNode | — | Sub-line under the title. |
children | ReactNode | — | Body content — the scroll region of the dialog. |
footer | ReactNode | — | Right-aligned footer slot (e.g. action Buttons). |
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | Dialog width on ≥sm screens (sm:max-w-sm → sm:max-w-4xl); full fills the viewport minus padding. |
className | string | — | Extra classes on the sheet container (wins over the size map via tailwind-merge). |
The header (with the × button) only renders when title or description is provided. The modal is
always a flex column — header/footer keep their natural height and the body is the scroll
region, so a tall modal can never push the footer past SheetModal's max-h-[85vh].
SheetModal
The responsive shell under Modal — you can use it directly for a custom, chromeless dialog. Centered
dialog on desktop (sm: and up), drag-to-dismiss bottom sheet on mobile (drag="y" with a release
threshold of 120px). Reduced motion disables the drag.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Whether the sheet is shown. |
onClose | () => void | — | Called on backdrop click, Escape, or a downward drag past threshold. |
children | ReactNode | — | Sheet content. |
labelledBy | string | — | id of the heading inside, for aria-labelledby. |
className | string | — | Extra classes on the sheet container. |
Popover
An anchored floating panel. The trigger element's own onClick is preserved (the panel toggles in
addition to it).
| Prop | Type | Default | Description |
|---|---|---|---|
trigger | ReactElement | — | The clickable trigger (its onClick is preserved; aria-haspopup/aria-expanded are injected). |
children | ReactNode | ((close) => ReactNode) | — | Panel content — a node, or a render function receiving a close callback. |
align | 'start' | 'end' | 'start' | Horizontal edge to anchor the panel to. |
side | 'top' | 'bottom' | 'bottom' | Preferred vertical side — 'top' drops up (e.g. a composer at the viewport bottom). Honored in both modes; the portal treats it as a preference it may flip. |
portal | boolean | false | Render the panel in a document.body portal (fixed-positioned) so it isn't clipped by a scrollable/overflow ancestor. Viewport-aware: measures the panel, flips above/below when the preferred side would overflow, clamps horizontally on-screen, and repositions on scroll/resize so it stays anchored. |
className | string | — | Extra classes on the panel. |
The render-function form (children={(close) => …}) lets content close the popover after an action —
this is exactly how Dropdown builds its auto-closing menu.
Dropdown (+ sub-parts)
A menu built on Popover. DropdownItems auto-close the menu when selected, via a CloseContext the
Dropdown provides.
Dropdown
| Prop | Type | Default | Description |
|---|---|---|---|
trigger | ReactElement | — | The clickable trigger. |
children | ReactNode | — | DropdownItem / DropdownSub / DropdownSeparator / DropdownLabel rows. |
align | 'start' | 'end' | 'start' | Anchor edge (forwarded to Popover). |
side | 'top' | 'bottom' | 'bottom' | Open above the trigger instead of below (forwarded to Popover). |
portal | boolean | false | Portal the menu to document.body so it isn't clipped by an overflow ancestor. |
className | string | — | Extra classes on the menu panel. |
DropdownItem
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Item label. |
onSelect | () => void | — | Runs on click; the menu then auto-closes. |
icon | ReactNode | — | Leading icon (sized to h-4 w-4). |
tone | 'default' | 'danger' | 'default' | danger colors the row with the danger token. |
disabled | boolean | — | Non-interactive, dimmed. |
closeOnSelect | boolean | true | Pass false to keep the menu open — for checkbox/filter menus. |
DropdownSub — a submenu row that flies out a nested menu on hover, click, or ArrowRight
(ArrowLeft/Escape closes it back). The flyout is a normal DOM child (not its own portal), so it stays
inside the parent's dismiss scope and selecting a leaf still closes the whole menu via the
CloseContext. It opens to the right and is viewport-aware — it flips to the left when it would
overflow the right edge and flips upward when it would overflow the bottom (measured pre-paint, so
the corrected position never jumps). The flyout animates in from the parent edge (fade + scale +
slide, tokens DURATION.fast/EASE_OUT; plain fade under reduced motion), and a short hover grace
period keeps it open across the diagonal row→flyout mouse path. Nest DropdownSub inside itself for
deeper menus — up to 3 levels.
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Row label (a chevron-right glyph is appended automatically). |
icon | ReactNode | — | Leading icon (sized to h-4 w-4). |
children | ReactNode | — | Nested DropdownItem / DropdownSub rows. |
disabled | boolean | — | Non-interactive, dimmed. |
DropdownSeparator — a thin role="separator" divider. DropdownLabel — an uppercase section
label (e.g. "Signed in as …").
Tabs
Controlled or uncontrolled tab group in four visual variants. Pair TabList/Tab with TabPanel. The
active-tab indicator slides between tabs using a shared layoutId (derived from useId, so multiple
Tabs instances on one page never collide) — no position math.
Keyboard & ARIA (WAI-ARIA tabs pattern, automatic activation): the list is role="tablist", each tab
role="tab" + aria-selected, panels role="tabpanel". A roving tabindex keeps only the active tab
in the Tab sequence; ArrowLeft/ArrowUp and ArrowRight/ArrowDown move focus to the previous/next
tab (wrapping, orientation-agnostic), Home/End jump to the first/last — moving focus also selects.
Tabs
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | — (required) | Initially selected tab value (uncontrolled seed). |
value | string | — | Controlled selected value (overrides internal state). |
onValueChange | (v: string) => void | — | Fires on selection (required for controlled use). |
variant | 'underline' | 'pills' | 'segmented' | 'boxed' | 'underline' | Sliding underline, solid pills, a segmented control on a muted track, or boxed card-top tabs. |
children | ReactNode | — | A TabList + TabPanels. |
className | string | — | Wrapper classes. |
TabList — the role="tablist" row that owns the arrow-key handler (children, className).
Tab — {value, children}; renders the sliding indicator when selected (each Tab is isolate so
its -z-10 highlight stays above the panel background — a regression to watch). TabPanel —
{value, children, className}; renders only when its value is active and fades/rises in.
Accordion
Expand/collapse sections in four visual variants with two indicator styles; controlled or uncontrolled.
Bodies animate open/closed via the shared Collapse motion wrapper; headers carry aria-expanded.
Accordion
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'single' | 'multiple' | 'single' | single closes others when one opens; multiple allows many open at once. |
variant | 'default' | 'separated' | 'filled' | 'flush' | 'default' | Bordered container, spaced item cards, tinted item cards, or borderless divided rows. |
indicator | 'chevron' | 'plus' | 'chevron' | Header toggle icon: rotating chevron, or a swapping plus/minus. |
defaultValue | string | string[] | — | Initially open item value(s) — uncontrolled mode. |
value | string | string[] | — | Open item(s) — controlled mode (pair with onValueChange). Overrides defaultValue. |
onValueChange | (open: string[]) => void | — | Fires with the next open-item array on every toggle (both modes). |
children | ReactNode | — | AccordionItems. |
className | string | — | Wrapper classes (win over the variant container classes via tailwind-merge). |
AccordionItem — {value, title, children, icon?, className?}. value identifies the item for
open/close bookkeeping; title is the clickable header; icon is an optional leading icon; children
is the collapsible body.
Configuration & customization
- Toast position and page transitions are global config (
LayoutContext) but overlays themselves are not config-driven — they take props at the call site. - Colors come from semantic tokens, so every overlay re-skins and flips light/dark automatically. Never hardcode a hex in a trigger or panel; add/adjust a token instead (see Design tokens & dark mode).
- Motion timing comes from
src/lib/motion.ts(popUpfor pop-in panels,springfor the tab indicator and accordion chevrons). Tune it there, not inline. - Portaling is opt-in per call (
portalonPopover/Dropdown). Turn it on when the trigger sits inside anoverflowcontainer.
Examples
Confirmation modal
const [open, setOpen] = useState(false)
<Modal
open={open}
onClose={() => setOpen(false)}
title="Delete project?"
description="This action cannot be undone."
footer={
<>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="danger" onClick={() => setOpen(false)}>Delete</Button>
</>
}
>
<p className="text-sm text-muted-foreground">
All data associated with this project will be permanently removed.
</p>
</Modal>Dropdown menu with a label, icons and a danger action
<Dropdown
align="end"
trigger={
<Button variant="outline">
Actions
<ChevronDown className="h-4 w-4" />
</Button>
}
>
<DropdownLabel>Manage</DropdownLabel>
<DropdownItem icon={<Pencil />} onSelect={() => edit()}>Edit</DropdownItem>
<DropdownItem icon={<Copy />} onSelect={() => duplicate()}>Duplicate</DropdownItem>
<DropdownSeparator />
<DropdownItem icon={<Trash2 />} tone="danger" onSelect={() => remove()}>
Delete
</DropdownItem>
</Dropdown>Portaled dropdown inside a scrollable column (e.g. a kanban card menu)
<Dropdown portal align="end" trigger={<button aria-label="Card menu"><MoreHorizontal /></button>}>
<DropdownItem onSelect={markDone}>Mark done</DropdownItem>
<DropdownItem tone="danger" onSelect={deleteCard}>Delete</DropdownItem>
</Dropdown>Nested submenus (DropdownSub, up to 3 levels — see /ui/dropdowns)
<Dropdown trigger={<Button variant="outline">Organize</Button>}>
<DropdownItem icon={<Star />} onSelect={favorite}>Add to favorites</DropdownItem>
<DropdownSub icon={<FolderInput />} label="Move to">
<DropdownItem onSelect={() => move('inbox')}>Inbox</DropdownItem>
<DropdownSub icon={<Clock />} label="Recent">
<DropdownItem onSelect={() => move('q3')}>Q3 report</DropdownItem>
</DropdownSub>
</DropdownSub>
</Dropdown>Filter menu that stays open (closeOnSelect={false})
<Dropdown trigger={<Button variant="outline">Filter</Button>}>
{filters.map((f) => (
<DropdownItem key={f.id} closeOnSelect={false} onSelect={() => toggle(f.id)}>
{f.label}
</DropdownItem>
))}
</Dropdown>Popover with arbitrary content (render-fn close)
<Popover
trigger={<Button variant="outline">Filters</Button>}
className="w-72"
>
{(close) => (
<div className="p-2 space-y-2">
<FilterControls />
<Button size="sm" onClick={close}>Apply</Button>
</div>
)}
</Popover>Tabs (uncontrolled, segmented variant)
<Tabs defaultValue="overview" variant="segmented">
<TabList>
<Tab value="overview">Overview</Tab>
<Tab value="activity">Activity</Tab>
<Tab value="settings">Settings</Tab>
</TabList>
<div className="pt-4">
<TabPanel value="overview">Summary…</TabPanel>
<TabPanel value="activity">Feed…</TabPanel>
<TabPanel value="settings">Preferences…</TabPanel>
</div>
</Tabs>Accordion (single-open FAQ)
<Accordion type="single" defaultValue="a0">
{faqs.map((f, i) => (
<AccordionItem key={i} value={`a${i}`} title={f.q}>
{f.a}
</AccordionItem>
))}
</Accordion>Best practices
- Own the
openstate forModal/SheetModal; letPopover/Dropdownown theirs. Dropdowns/popovers are self-contained — don't try to control their open state. - Turn on
portalwhenever the trigger lives in anoverflowcontainer (kanban columns, data-grid cells, panel headers). Otherwise the panel gets clipped. - Use the render-fn
childrenofPopoverto close after an action instead of tracking open state yourself. - Give every user-facing string an i18n key (see the demo pages, which pull labels from the
navnamespace). - Keep motion in tokens. Don't hardcode durations/springs inside these components' call sites.
- Always pass
defaultValue— it's required onTabs(the uncontrolled seed) and recommended onAccordionso the initial state is intentional rather than "nothing selected". - Group destructive rows and use nesting sparingly.
DropdownSubsupports 3 levels, but flat menus withDropdownLabelsections are usually easier to scan; keepcloseOnSelect={false}for genuine multi-select filters only.
Troubleshooting
- Dropdown/popover panel is cut off inside a scrolling area. Add
portal. The panel then renders in a body portal and repositions on scroll/resize so it stays anchored to the trigger instead of detaching. - Clicking the portaled panel closes it immediately. This shouldn't happen with the built-in
components —
Popoverpasses its panel ref throughuseDismiss'sextraRefs. If you build a custom portaled surface withuseDismiss, remember to pass its ref inextraRefs, or an outside-click check will treat it as "outside". - Two tab groups on one page share an underline / it jumps between them. Each
<Tabs>derives its ownlayoutIdfromuseId, so this only happens if you reuse the internal machinery by hand — always wrap each group in its own<Tabs>. - A popover opens on the "wrong" side near a viewport edge. That's the viewport-aware flip working as
designed —
side(andDropdownSub's rightward flyout) is a preference; when the preferred side would overflow, the panel flips to the side with more room and clamps on-screen. - A submenu closes while moving the mouse to it.
DropdownSubalready grants a short grace period for the diagonal row→flyout path; if you rebuild a flyout by hand, debounce themouseleavethe same way. - Accordion opens multiple sections when you expected one. Set
type="single"(the default) —multipleintentionally allows many open. - Modal doesn't close on Escape. Escape is wired in
SheetModalonly whileopenistrue; make sureopenreflects real state andonCloseactually flips it.
FAQ
- Can a Popover contain non-menu content? Yes —
Popovertakes any content.Dropdownis the menu-flavored wrapper (rows that auto-close). UsePopoverdirectly for filters, help text, forms. - Is the Modal focus-trapped and accessible? It renders
role="dialog"aria-modalwitharia-labelledbywired to the title, closes on Escape/backdrop, and becomes a drag-to-dismiss sheet on mobile. - How do I control a Tabs group from the URL or parent state? Pass
value+onValueChange(controlled). Omitvaluefor uncontrolled, usingdefaultValue.Accordionfollows the same pattern (value/onValueChangeover an open-item array). - Are the tabs keyboard-navigable? Yes — roving tabindex with ArrowLeft/Right/Up/Down (wrapping) and Home/End, with automatic activation per the WAI-ARIA tabs pattern. Submenus open on ArrowRight and close on ArrowLeft/Escape.
- Do these work under reduced motion? Yes — all animation goes through
motion/reactunder the globalMotionProvider(reducedMotion="user"); the SheetModal also disables dragging under reduced motion.
Notes for designers & content editors
- Dialog copy: keep
titleshort and action-oriented; put the consequence indescription. Footer buttons read left→right as secondary→primary (e.g. Cancel · Delete). - Menus: group related actions and separate destructive ones with a
DropdownSeparator; destructive rows usetone="danger". UseDropdownLabelfor context ("Signed in as …", "Download as") andDropdownSubfor hierarchies like "Move to…" — keep submenu labels short since they gain a trailing chevron. - Tabs vs Accordion: tabs for a small, flat set of peer views; accordion for a longer list of independently-openable sections (FAQs, settings groups).
- All of these adapt to every skin and to light/dark automatically — no per-skin design work needed.
Related
Core & Feedback
Button, Card, Alert, Toast and the primitives these compose with.
Forms
Input/Select/Field used inside modals and popovers.
Panel (Portlet)
The portlet card, which itself uses Dropdown and Collapse.
Animation & Effects
popUp, spring, Collapse, and the reduced-motion policy.
CSS System
The semantic color tokens every overlay uses.
Was this page helpful?
