Resolved: light

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.
tsx
"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>

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 (controlled) + Drawer.Content side=right|left, size=sm|md|lg
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.

default — trigger prop, optional title, bottom placement
side="right" align="start" — anchored placement
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 wraps the group; each Tooltip takes content + one child
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.

ConfirmDialog — open/onOpenChange + onConfirm; danger reddens the confirm
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.

useToast().add({ title, description, type }) — type ∈ 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.

controlled — value + onValueChange (variant="underline", default)

Pills — a softer, chip-like list.

variant="pills"

Segmented — an enclosed toggle group.

variant="segmented"
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.

multiple — several sections can be open at once (items + multiple)

A volunteer hub — your teams, events, representatives and the actions you can take, in one place.

single (default) — opening one section collapses the others
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).

controlled — open + onOpenChange, custom trigger node

Without open/onOpenChange the component manages its own state; defaultOpen sets the initial state.

uncontrolled — defaultOpen, the wrapper owns the 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.

native <details> — summary + content, no JS required
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.

open by default — 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.