PVR Tech Studio

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.

13 min read
Updated July 15, 2026

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 useDismiss hook (src/hooks/useDismiss.ts); modals wire their own Escape + backdrop-click listeners in SheetModal. Users can always close an overlay without reaching for a specific button.
  • Portaling to escape overflow clipping. Popover and Dropdown accept a portal prop that renders the panel into a document.body portal (fixed-positioned), so a menu opened inside a scrollable/overflow-hidden ancestor (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/SheetModal always 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

FileResponsibility
src/components/ui/Modal.tsxTitled dialog (title / description / footer, size sm→full) built on SheetModal.
src/components/motion/SheetModal.tsxThe 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.tsxAnchored floating panel. cloneElements the trigger; dismiss via useDismiss; optional viewport-aware document.body portal.
src/components/ui/Dropdown.tsxMenu built on PopoverDropdown + DropdownItem / DropdownSub / DropdownSeparator / DropdownLabel. Items auto-close via a CloseContext.
src/components/ui/Tabs.tsxTabs / TabList / Tab / TabPanel. Four visual variants; sliding indicator via a per-instance layoutId; roving-tabindex keyboard nav; controlled or uncontrolled.
src/components/ui/Accordion.tsxAccordion / AccordionItem. type single | multiple, four variants, chevron/plus indicators; bodies animate via Collapse; controlled or uncontrolled.
src/hooks/useDismiss.tsShared 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

A titled dialog rendered over SheetModal. Backdrop click and Escape close it.

PropTypeDefaultDescription
openbooleanWhether the dialog is shown (you control this).
onClose() => voidCalled on backdrop click, Escape, or the × button.
titleReactNodeHeading; wired to aria-labelledby.
descriptionReactNodeSub-line under the title.
childrenReactNodeBody content — the scroll region of the dialog.
footerReactNodeRight-aligned footer slot (e.g. action Buttons).
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'Dialog width on ≥sm screens (sm:max-w-smsm:max-w-4xl); full fills the viewport minus padding.
classNamestringExtra 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.

PropTypeDefaultDescription
openbooleanWhether the sheet is shown.
onClose() => voidCalled on backdrop click, Escape, or a downward drag past threshold.
childrenReactNodeSheet content.
labelledBystringid of the heading inside, for aria-labelledby.
classNamestringExtra 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).

PropTypeDefaultDescription
triggerReactElementThe clickable trigger (its onClick is preserved; aria-haspopup/aria-expanded are injected).
childrenReactNode | ((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.
portalbooleanfalseRender 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.
classNamestringExtra 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.

A menu built on Popover. DropdownItems auto-close the menu when selected, via a CloseContext the Dropdown provides.

Dropdown

PropTypeDefaultDescription
triggerReactElementThe clickable trigger.
childrenReactNodeDropdownItem / 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).
portalbooleanfalsePortal the menu to document.body so it isn't clipped by an overflow ancestor.
classNamestringExtra classes on the menu panel.

DropdownItem

PropTypeDefaultDescription
childrenReactNodeItem label.
onSelect() => voidRuns on click; the menu then auto-closes.
iconReactNodeLeading icon (sized to h-4 w-4).
tone'default' | 'danger''default'danger colors the row with the danger token.
disabledbooleanNon-interactive, dimmed.
closeOnSelectbooleantruePass 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.

PropTypeDefaultDescription
labelReactNodeRow label (a chevron-right glyph is appended automatically).
iconReactNodeLeading icon (sized to h-4 w-4).
childrenReactNodeNested DropdownItem / DropdownSub rows.
disabledbooleanNon-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

PropTypeDefaultDescription
defaultValuestring— (required)Initially selected tab value (uncontrolled seed).
valuestringControlled selected value (overrides internal state).
onValueChange(v: string) => voidFires 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.
childrenReactNodeA TabList + TabPanels.
classNamestringWrapper 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

PropTypeDefaultDescription
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.
defaultValuestring | string[]Initially open item value(s) — uncontrolled mode.
valuestring | string[]Open item(s) — controlled mode (pair with onValueChange). Overrides defaultValue.
onValueChange(open: string[]) => voidFires with the next open-item array on every toggle (both modes).
childrenReactNodeAccordionItems.
classNamestringWrapper 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 (popUp for pop-in panels, spring for the tab indicator and accordion chevrons). Tune it there, not inline.
  • Portaling is opt-in per call (portal on Popover/Dropdown). Turn it on when the trigger sits inside an overflow container.

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 open state for Modal/SheetModal; let Popover/Dropdown own theirs. Dropdowns/popovers are self-contained — don't try to control their open state.
  • Turn on portal whenever the trigger lives in an overflow container (kanban columns, data-grid cells, panel headers). Otherwise the panel gets clipped.
  • Use the render-fn children of Popover to 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 nav namespace).
  • Keep motion in tokens. Don't hardcode durations/springs inside these components' call sites.
  • Always pass defaultValue — it's required on Tabs (the uncontrolled seed) and recommended on Accordion so the initial state is intentional rather than "nothing selected".
  • Group destructive rows and use nesting sparingly. DropdownSub supports 3 levels, but flat menus with DropdownLabel sections are usually easier to scan; keep closeOnSelect={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 — Popover passes its panel ref through useDismiss's extraRefs. If you build a custom portaled surface with useDismiss, remember to pass its ref in extraRefs, 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 own layoutId from useId, 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 (and DropdownSub'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. DropdownSub already grants a short grace period for the diagonal row→flyout path; if you rebuild a flyout by hand, debounce the mouseleave the same way.
  • Accordion opens multiple sections when you expected one. Set type="single" (the default) — multiple intentionally allows many open.
  • Modal doesn't close on Escape. Escape is wired in SheetModal only while open is true; make sure open reflects real state and onClose actually flips it.

FAQ

  • Can a Popover contain non-menu content? Yes — Popover takes any content. Dropdown is the menu-flavored wrapper (rows that auto-close). Use Popover directly for filters, help text, forms.
  • Is the Modal focus-trapped and accessible? It renders role="dialog" aria-modal with aria-labelledby wired 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). Omit value for uncontrolled, using defaultValue. Accordion follows the same pattern (value/onValueChange over 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/react under the global MotionProvider (reducedMotion="user"); the SheetModal also disables dragging under reduced motion.

Notes for designers & content editors

  • Dialog copy: keep title short and action-oriented; put the consequence in description. 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 use tone="danger". Use DropdownLabel for context ("Signed in as …", "Download as") and DropdownSub for 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.

Was this page helpful?