Overlays
The transient, floating and expand/collapse surfaces from @phauna/ds/overlays — everything that opens over, beside, or out of the page rather than sitting inline. Each is a thin token-driven wrapper over a Base UI primitive (Dialog, Menu, Tooltip, Popover, Toast, Tabs, Accordion, Collapsible) that brings the focus trap, keyboard handling, outside-press / Esc dismissal and ARIA wiring for free; the native Disclosure is the zero-JS <details> option. They are all CLIENT components, so every demo below lives in one colocated 'use client' island (./OverlaysDemo) and this page is a server component that mounts it. Import every component from @phauna/ds/overlays. Each example is opened by a real trigger Button — click through them, then flip the theme / brand / density in the top bar to confirm each surface re-skins from the semantic tokens, and tab through to check focus moves correctly.
- import path
- Every component on this page comes from @phauna/ds/overlays (Modal, Drawer, Popover, Tooltip, TooltipProvider, Menu, ConfirmDialog, Toaster, useToast, Tabs, Accordion, Collapsible, Disclosure).
- client boundary
- All are client components — they own open/close state, focus and keyboard. Render them from a server page only inside a 'use client' island (here ./OverlaysDemo).
- controlled trigger pattern
- The DS <Button> does not forward a ref, so it can't be a Base UI render-prop trigger. The dialog-style demos drive their Root controlled (open + onOpenChange) and open from a plain Button onClick — Base UI still provides the focus trap, Esc and backdrop dismissal.
- toasts need the provider
- useToast() resolves against a <Toaster> ancestor — the whole page is wrapped in one so the viewport mounts once and Menu / ConfirmDialog / Toaster demos can all enqueue.
- accessibility
- Every dialog has a Title (aria-labelledby), tooltips never carry the only meaning (the trigger is labelled), and icon-only triggers get an aria-label. Focus returns to the trigger on close.
- theme-aware
- Backdrops, panels, popups, toasts and tab indicators are all CSS-token driven (.overlay-backdrop / .modal / .drawer / .popup / .tooltip / .toast / .tabs), so they reskin with the theme / brand / density switches.
"use client";
import { useState } from "react";
import { Modal } from "@savethemarshalldogs/design-system/overlays";
import { Button } from "@/components/ui";
const [open, setOpen] = useState(false);
<Modal.Root open={open} onOpenChange={setOpen}>
<Modal.Content size="md">
<Modal.Head>
<Modal.Title>Remove volunteer?</Modal.Title>
</Modal.Head>
<Modal.Body>This unassigns them from every team.</Modal.Body>
<Modal.Foot>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="danger" onClick={remove}>Remove</Button>
</Modal.Foot>
</Modal.Content>
</Modal.Root>Modal
A centred, focus-trapping dialog over a dimmed backdrop — for a confirmation, a short form, or content that demands the user's full attention before they continue. Compose it from Modal.Root (the open-state owner) and Modal.Content (which renders the portal, backdrop and panel), with Head / Title / Description / Body / Foot for structure. Content takes size md (default) or lg.
- Modal.Root
- The Base UI Dialog.Root. Controlled via open + onOpenChange(open) (or uncontrolled via defaultOpen). modal is true by default (focus trap + scroll lock).
- Modal.Content
- Renders Portal + Backdrop (.overlay-backdrop) + the panel. size="md" (default) | "lg" sets the max-width. aria-modal is set for you.
- Modal.Head / Title / Description
- Title is wired to aria-labelledby and Description to aria-describedby — keep one Title per dialog so it has an accessible name.
- Modal.Body / Foot
- Body is the scrollable content region; Foot is the right-aligned action row (use the primary + ghost pairing).
- Modal.Trigger / Modal.Close
- Base UI render-prop trigger / close button. Here we drive open from a plain Button onClick instead (the DS Button doesn't forward a ref), and close via onOpenChange(false). Esc and a backdrop press also close it.
Drawer
A panel that slides in from a screen edge for a secondary flow — filters, a detail view, a quick-edit form — without leaving the page. Same Root / Content / Head / Body / Foot shape as Modal, but Content takes a side (right default | left) and a size (sm | md | lg). On small viewports the panel docks to the bottom as a sheet.
- Drawer.Root
- The Dialog.Root again — controlled via open + onOpenChange (or defaultOpen). Same focus-trap / Esc / backdrop behaviour as Modal.
- Drawer.Content side
- "right" (default) | "left" — which edge the panel slides from.
- Drawer.Content size
- "sm" | "md" (default) | "lg" — the panel width on desktop.
- mobile sheet
- On a narrow viewport the drawer becomes a bottom sheet — the same component, no extra markup. Resize the window to see it.
- Head / Body / Foot
- Head holds the Title; Body is the content; Foot the action row. Keep a Drawer.Title so the panel is named for assistive tech.
Popover
A small floating panel anchored to its trigger — a richer alternative to a tooltip when you need real, interactive content (a settings cluster, a mini-form, a help panel). The convenience wrapper takes the trigger as a prop, renders the portal + positioner, and closes on outside-press and Esc. side / align steer placement; it flips automatically near a viewport edge.
- trigger
- ReactNode — the anchor element the panel hangs off (rendered inside a span). Click it to open.
- title
- Optional ReactNode — a heading rendered at the top of the panel.
- children
- The panel content — interactive controls are fine (unlike a tooltip).
- side / align / sideOffset
- side ∈ top|bottom|left|right (default bottom); align ∈ start|center|end; sideOffset is the px gap from the trigger. Base UI flips it to stay on-screen.
- Popover vs Tooltip vs Menu
- Popover = interactive floating content; Tooltip = a passive hint; Menu = a list of actions.
Tooltip
A passive hint that appears on hover or keyboard focus. Wrap the region in one TooltipProvider (it shares the open/close delay across the group), then give each Tooltip a content string and a single focusable child as its anchor. A tooltip must never be the only carrier of meaning — the trigger always keeps its own visible or aria label.
- TooltipProvider
- Wrap once around a group (or the app). Holds the shared delay / closeDelay so tooltips in the group open/close consistently.
- content
- ReactNode — the hint text shown in the floating bubble.
- children
- Exactly one focusable element — it becomes the trigger (rendered via Base UI's render prop). It must carry its own label.
- sideOffset
- px gap between the trigger and the bubble. Placement flips automatically near an edge.
- never meaning-only
- Hover/focus-only content is invisible to touch and skimming — use a tooltip to add detail, never to hide the only label (e.g. give an icon-only trigger an aria-label too).
ConfirmDialog (destructive confirm)
A focused yes/no confirmation built on an alert dialog — the guardrail before a destructive or irreversible action. It's a single ready-made component: pass the open state, a title + description, the two button labels, and onConfirm. danger paints the confirm button red; the cancel button is always the safe escape. onConfirm runs your action, then the dialog closes.
- open / onOpenChange
- Controlled visibility — open the dialog by setting open=true, and it calls onOpenChange(false) on cancel / Esc / backdrop / after confirm.
- title / description
- title names the dialog (required, aria-labelledby); description explains the consequence (aria-describedby).
- confirmLabel / cancelLabel
- The two button labels (default "Confirm" / "Cancel"). Make confirm specific — "Delete forever", not "OK".
- danger
- boolean — renders the confirm button in the destructive (red) variant. Use it only for genuinely destructive actions.
- onConfirm
- () => void — runs the action when the user confirms; the dialog then closes. The cancel path just dismisses.
Toaster + useToast
Transient, non-blocking notifications — the after-the-fact 'Saved' / 'Couldn't save' feedback that auto-dismisses (and pauses on hover). Mount one Toaster near the app root (here it wraps this whole page); anywhere beneath it, useToast().add({ title, description, type }) enqueues a toast. The type picks the tone — good / warn / bad / info.
- Toaster
- Mount once near the root. Provides the toast context (useToast) and renders the viewport (.toaster, top-right). Children render inside its provider.
- useToast()
- Returns the toast manager. The page must be inside a <Toaster> for it to resolve.
- add({ title, description, type })
- Enqueues a toast. title is the headline, description the optional sub-line, type ∈ "good" | "warn" | "bad" | "info" picks the tone (default info). Returns the toast id.
- auto-dismiss
- Toasts time out on their own and pause while hovered; each carries a × close button. Use timeout in the options to override the duration.
- use sparingly
- Toasts are easy to miss — confirm a result, don't gate a flow. For anything the user must acknowledge, use a Modal / ConfirmDialog instead.
Tabs (controlled)
Switch between peer views in the same context without navigating away. Compose Tabs.Root (the value owner) → Tabs.List of Tabs.Tab triggers → a Tabs.Panel per value. Drive it controlled with value + onValueChange (or uncontrolled with defaultValue). variant changes the look — underline (default), pills, segmented — and a Tab can carry a count badge. It's fully keyboard-navigable.
The active tab is driven by React state (value: overview). Arrow keys move between tabs; the panel is wired to its tab for assistive tech.
Pills — a softer, chip-like list.
Segmented — an enclosed toggle group.
- Tabs.Root value / defaultValue
- The active tab value — controlled (value + onValueChange(value)) or uncontrolled (defaultValue).
- variant
- "underline" (default) | "pills" | "segmented" | "vertical" — the visual treatment only; behaviour is identical.
- Tabs.List / Tabs.Tab
- List is the tab strip; each Tab carries a value matching a Panel. Tab takes an optional count badge.
- Tabs.Panel
- The content for a value — only the active panel shows. Each panel is wired to its tab for assistive tech.
- keyboard
- Arrow keys move between tabs; Home/End jump to the first/last; the panel is reachable via Tab.
Accordion (multiple)
A stack of headers that expand to reveal their content — an FAQ, a set of grouped settings, progressive detail. The convenience wrapper takes a typed items array ({ value, title, content, disabled? }) and renders the whole stack. By default opening one section collapses the others; pass multiple to let several stay open at once.
A volunteer hub — your teams, events, representatives and the actions you can take, in one place.
A volunteer hub — your teams, events, representatives and the actions you can take, in one place.
- items
- AccordionItem[] — { value: string; title: ReactNode; content: ReactNode; disabled? }. The whole stack is rendered from this array.
- multiple
- boolean — allow more than one section open at once (maps to Base UI openMultiple). Default false (single-open).
- value / defaultValue
- Which section value(s) are open — controlled (value + onValueChange) or uncontrolled (defaultValue, an array of open values).
- disabled item
- Set disabled on an item to lock it shut and grey its header.
- Accordion vs Collapsible
- Accordion coordinates several sections as a set; Collapsible is one independent show/hide region.
Collapsible
A single show/hide region with its own trigger and an animated open/close — for one block of secondary content you want collapsed by default (advanced options, an optional note). The convenience wrapper takes the trigger as a prop; drive it controlled (open + onOpenChange) or let it manage itself (defaultOpen).
Without open/onOpenChange the component manages its own state; defaultOpen sets the initial state.
- trigger
- ReactNode — the toggle's label/content (rendered inside the trigger button, wired with aria-expanded).
- open / onOpenChange
- Controlled state — open + onOpenChange(open). Omit both and use defaultOpen for an uncontrolled region.
- children
- The content revealed when open. The panel animates its height open/closed.
- Collapsible vs Accordion vs Disclosure
- Collapsible = one JS-animated region with controllable state; Accordion = a coordinated set; Disclosure = the zero-JS native option below.
Disclosure (native)
The zero-JavaScript option: a styled native <details>/<summary>. It needs no React state, works even before hydration, and is the right choice when you don't need controlled state or an animation — an inline 'read more', a help note, a long-form FAQ entry. Pass the summary; native attributes like open pass straight through.
What documents do I need to foster?
A signed foster agreement and a quick home check. The medical team handles supplies.
Where do I park at events?
Volunteer parking is the north lot; the kiosk has a map. This one is rendered open by default via the native open attribute.
- summary
- ReactNode — the always-visible clickable label (the native <summary>).
- children
- The content revealed when expanded.
- open
- The native <details open> boolean — render it expanded by default. (It's uncontrolled native state; the browser toggles it.)
- rest props
- Spreads DetailsHTMLAttributes onto the <details> (id, className, onToggle …).
- why native
- No JS, no hydration cost, accessible by default — reach for Disclosure first; step up to Collapsible only when you need controlled state or animation.