@vinyasa/overlay
v2.0.3
Published
Overlay primitives: Dialog (+ `Modal` alias), Alert Dialog, Sheet (the `Drawer` role — see below), Tooltip, Popover, Hover Card, Context Menu, Dropdown Menu, Command Palette.
Readme
@vinyasa/overlay
Overlay primitives: Dialog (+ Modal alias), Alert Dialog, Sheet (the Drawer role — see below), Tooltip, Popover, Hover Card, Context Menu, Dropdown Menu, Command Palette.
Installation
pnpm add @vinyasa/overlay @vinyasa/layout @vinyasa/typography @vinyasa/icons @vinyasa/button @vinyasa/tokens @radix-ui/react-dialog @radix-ui/react-alert-dialog @radix-ui/react-tooltip @radix-ui/react-popover @radix-ui/react-hover-card @radix-ui/react-dropdown-menu @radix-ui/react-context-menu cmdk react react-domRendering requires a VinyasaProvider (from @vinyasa/tokens) above these components in the tree. None of these components render through a portal — VinyasaProvider injects theme tokens as an inline style on its own wrapper element (not :root), so anything portaled to document.body would fall outside that subtree and lose every var(--vinyasa-*) token. Each overlay's content instead uses position: fixed to float above the rest of the page while staying inside the themed subtree — the same fix already used by @vinyasa/form's Select/Combobox/Date Picker/Color Picker and @vinyasa/feedback's Toaster. This means your app must not wrap VinyasaProvider's subtree in an ancestor with its own transform/filter/perspective (which would give position: fixed descendants a new containing block other than the viewport).
This package has no root export — every component is subpath-only (import { Dialog } from '@vinyasa/overlay/dialog', never from '@vinyasa/overlay'). A root barrel re-exporting all these components would let a bundler tree-shake the unused JS down to just the ones you import, but the CSS side-effect imports the others carry are not eligible for the same tree-shaking (confirmed empirically with both esbuild and Rollup on this monorepo's other packages). Removing the root entry entirely makes that the only possible outcome, not something that depends on your bundler being clever enough to shake it out.
Composition
Every component ships both a simple, prop-driven call and a compound .Root/.Trigger/.Content/... namespace — never flat sibling exports (DialogTrigger, DialogContent, etc. don't exist; only Dialog.Trigger, Dialog.Content do). Reach for the simple call first — <Dialog title=… description=… footer=…> covers the common shape for Dialog/Sheet/AlertDialog, and <Tooltip label=…> covers Tooltip completely for almost every real use. Drop down to the compound API only for what the simple call can't express: an icon next to a title, custom body layout, more than two AlertDialog actions. An earlier single-prop <Dialog trigger= title= footer=> shape (a single flat prop bag, no compound underneath at all) was tried and dropped — a string-typed title couldn't fit an icon next to it, and there was no escape hatch once a use case outgrew the prop list. The current shape keeps the easy case easy without capping what's possible.
Dialog/Sheet/AlertDialog deliberately take no trigger prop — the caller's trigger button lives wherever it wants in the tree and just flips open (or uses defaultOpen when nothing needs to observe the state). Every anchor-based overlay is the exception: Radix has to measure and attach listeners to an actual anchor element, so the anchor has to stay structurally inside Root. Tooltip/Hover Card/Context Menu use children as that anchor directly (Context Menu wraps the whole right-clickable area, not a single trigger element); Popover/Dropdown Menu take a trigger prop instead, since their own children more naturally reads as "what's inside the popover" (<Popover trigger={<Button>Filters</Button>}><FilterForm /></Popover>). Either way, the element itself can be anything via Radix's own asChild.
Usage
Dialog
import { Dialog } from '@vinyasa/overlay/dialog';
import Button from '@vinyasa/button/button';
import Input from '@vinyasa/form/input';
import { useState } from 'react';
function EditProfileDialog() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Edit profile</Button>
<Dialog
open={open}
onOpenChange={setOpen}
title="Edit profile"
description="Make changes to your profile here."
footer={
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => setOpen(false)}>Save changes</Button>
</>
}
>
<Input label="Name" />
</Dialog>
</>
);
}sizepicks a max-width preset:'sm' | 'md' | 'lg' | 'xl'(defaults to'md', 28rem).- A close (X) button renders in the corner automatically; pass
hideCloseButtonto omit it. title/description/footerareReactNode, notstring— an icon next to the title works with no escape hatch needed.
Compound API, for custom body layout or an icon next to the title:
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Content size="lg">
<Dialog.Header>
<Dialog.Title>Edit profile</Dialog.Title>
<Dialog.Description>Make changes to your profile here.</Dialog.Description>
</Dialog.Header>
<Dialog.Body>
<Input label="Name" />
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close asChild>
<Button variant="outline">Cancel</Button>
</Dialog.Close>
<Button>Save changes</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>Dialog.Trigger asChild also works if you'd rather let Dialog own its own open state uncontrolled, instead of the open/onOpenChange pattern above.
Also available as Modal/Modal.Root/Modal.Trigger/... — Modal and Dialog are the same implementation under two names, not two components.
Alert Dialog
Same shape as Dialog, reserved for interruptions the user must resolve explicitly — a destructive or unrecoverable action. @radix-ui/react-alert-dialog disables outside-click and Escape dismissal at the primitive level, and there's no generic close button — only an explicit confirm or cancel.
import { AlertDialog } from '@vinyasa/overlay/alert-dialog';
import Button from '@vinyasa/button/button';
import { useState } from 'react';
function DeleteProjectButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="danger" onClick={() => setOpen(true)}>
Delete project
</Button>
<AlertDialog
open={open}
onOpenChange={setOpen}
title="Delete project"
description="This cannot be undone."
confirmLabel="Delete"
onConfirm={() => {
// perform the delete
}}
/>
</>
);
}confirmLabel/cancelLabeldefault to'Confirm'/'Cancel'.onConfirm/onCancelboth close the dialog automatically after running.sizetakes the same'sm' | 'md' | 'lg' | 'xl'preset asDialog.
Drop to AlertDialog.Root/.Trigger/.Content/.Header/.Body/.Footer/.Title/.Description/.Action/.Cancel for a choice among more than two actions.
Sheet
Same shape as Dialog again, but edge-anchored and sliding in from that edge instead of centered and scaling in. This is the Drawer role other libraries name separately — same Radix primitive (@radix-ui/react-dialog), same edge-anchored behavior, so it isn't duplicated under a second name here.
import { Sheet } from '@vinyasa/overlay/sheet';
import Button from '@vinyasa/button/button';
import { useState } from 'react';
function FiltersSheet() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
Filters
</Button>
<Sheet open={open} onOpenChange={setOpen} title="Filters" side="right">
{/* filter controls */}
</Sheet>
</>
);
}sidepicks which edge it slides in from:'top' | 'right' | 'bottom' | 'left'(defaults to'right').- Everything else —
title/description/footer/hideCloseButton/compoundSheet.Root/.Trigger/... — matchesDialogexactly.
Tooltip
Needs nothing mounted anywhere else in your app first — no provider to set up near the root, unlike some other Radix-based tooltip wrappers.
import { Tooltip } from '@vinyasa/overlay/tooltip';
import Button from '@vinyasa/button/button';
<Tooltip label='Redeploys the latest commit on "main".'>
<Button variant="outline">Redeploy</Button>
</Tooltip>;childrenis the single anchor element the tooltip attaches hover/focus listeners to and positions against.- Opens after a 100ms hover delay by default — near-instant, not Radix's own 700ms default, which reads as sluggish. Override per instance with
delayDuration(ms). side/align/sideOffsetcontrol placement;hideArrowomits the pointer triangle.aria-labelon the trigger element still carries its accessible name for icon-only buttons — the tooltip is a supplementary visual hint, not a replacement for one.
Compound API, for content beyond plain text (an icon next to the label, for instance):
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant="outline">Redeploy</Button>
</Tooltip.Trigger>
<Tooltip.Content side="top">
<InfoIcon aria-hidden="true" />
Redeploys the latest commit on "main".
</Tooltip.Content>
</Tooltip.Root>Tooltip.Root mounts its own provider internally too — same zero-setup behavior as the simple call above. The rare case that genuinely needs one shared hover-skip-delay window across several tooltips at once (Radix's own reason a separate Provider exists) can still reach for @radix-ui/react-tooltip's own Provider directly.
Popover
import { Popover } from '@vinyasa/overlay/popover';
import Button from '@vinyasa/button/button';
import { Checkbox } from '@vinyasa/form/checkbox';
<Popover trigger={<Button variant="outline">Filters</Button>}>
<Checkbox label="Production only" />
</Popover>;triggeris required — the element the popover is anchored to and toggled by on click.childrenis the popover's own body content.side/align/sideOffsetcontrol placement;hideArrowomits the pointer triangle.
Drop to Popover.Root/.Trigger/.Anchor/.Content/.Close/.Arrow for a positioning anchor separate from the trigger, or custom content layout.
Hover Card
Same shape as Popover, but hover-revealed like Tooltip — for a richer preview (a profile card, a link summary) than a one-line tooltip label fits.
import { HoverCard } from '@vinyasa/overlay/hover-card';
import Heading from '@vinyasa/typography/heading';
import Text from '@vinyasa/typography/text';
<HoverCard trigger={<a href="/team/jamie">@jamie</a>}>
<Heading as="h4" size="sm">
Jamie Reyes
</Heading>
<Text size="sm" color="muted">
Deployed 12 times this week
</Text>
</HoverCard>;- Opens/closes after a 300ms hover delay by default — slower than Tooltip's 100ms, since a richer preview shouldn't reveal itself on every incidental hover. Override with
openDelay/closeDelay(ms). - No provider needed, same as Tooltip, but for a different reason: Hover Card's own delay isn't shared across instances the way Tooltip's is, so there's no Provider to mount in the first place.
Dropdown Menu
import { DropdownMenu } from '@vinyasa/overlay/dropdown-menu';
import Button from '@vinyasa/button/button';
<DropdownMenu
trigger={<Button variant="outline">Actions</Button>}
items={[
{ type: 'item', key: 'rename', label: 'Rename' },
{ type: 'separator', key: 'sep' },
{ type: 'item', key: 'delete', label: 'Delete', destructive: true },
]}
/>;itemsis a flat array of{ type: 'item' | 'separator' | 'label', ... }entries — the common case of a flat action list.- An item's
destructive: truestyles it as a destructive action (e.g. Delete);icon/shortcutadd a leading icon or trailing shortcut hint.
Checkbox items, radio items, and submenus aren't expressible via the flat items array — drop to DropdownMenu.Root/.Trigger/.Content/.Item/.CheckboxItem/.RadioGroup/.RadioItem/.Label/.Separator/.Sub/.SubTrigger/.SubContent for those.
Context Menu
Same item shape and chrome as Dropdown Menu — Radix ships near-identical primitives for both, and a context-menu item reads identically to a dropdown item, just triggered by right-click on a wrapped area instead of a click on a trigger button.
import { ContextMenu } from '@vinyasa/overlay/context-menu';
<ContextMenu items={[{ type: 'item', key: 'rename', label: 'Rename' }]}>
<ProjectRow />
</ContextMenu>;childrenis the area that responds to right-click — there's no separate trigger element the way Popover/Dropdown Menu have one.- Same compound escape hatch as Dropdown Menu:
ContextMenu.Root/.Trigger/.Content/.Item/.CheckboxItem/.RadioGroup/.RadioItem/.Label/.Separator/.Sub/.SubTrigger/.SubContent.
Controlled open — reliable for closing a menu that a real right-click already opened (e.g. dismissing it if the row it's anchored to gets deleted by another collaborator in real time), not for opening one at a sensible position out of nowhere:
const [open, setOpen] = useState(false);
// e.g. inside a websocket handler:
// onRowDeleted(() => setOpen(false));
<ContextMenu open={open} onOpenChange={setOpen} items={[...]}>
<ProjectRow />
</ContextMenu>;Setting open to true before a real right-click/long-press has fired anchors the menu to the viewport's top-left corner instead — position comes from the pointer coordinates the trigger's own contextmenu handler captures, not from this prop (Radix's own runtime warns about this in development). There's also no defaultOpen — Radix's useControllableState call for this component hardcodes that to false regardless of what's passed. Reach for Popover/Dropdown Menu instead for a button-triggered case that needs real positioning.
Command Palette
A keyboard-driven, filterable list of grouped actions shown as a top-anchored modal (not vertically centered like Dialog) — the established convention (macOS Spotlight, VS Code's command palette). Typically opened via a global shortcut your app wires up itself; this component only renders once open/defaultOpen says so.
import { CommandPalette } from '@vinyasa/overlay/command-palette';
import { useState } from 'react';
function App() {
const [open, setOpen] = useState(false);
// wire `open`/`onOpenChange` to your own global Cmd+K listener
return (
<CommandPalette
label="Command palette"
placeholder="Type a command or search..."
open={open}
onOpenChange={setOpen}
groups={[
{
key: 'actions',
heading: 'Actions',
items: [{ key: 'new-project', label: 'New project', shortcut: '⌘N' }],
},
]}
/>
);
}labelis required and never shown visually — a command palette is search-first, so it's exposed only to assistive tech as the dialog's accessible name.groupsis an array of{ key, heading?, items }— each item is{ key, label, onSelect?, disabled?, icon?, shortcut?, keywords? }.- Built on
cmdkfor filtering/keyboard nav, composed with Radix's ownDialog.Root/.Contentdirectly (notcmdk's ownCommand.Dialog, which unconditionally portals intodocument.body— see the component's own comment on why that would breakVinyasaProvider's theme scoping).
Drop to CommandPalette.Root/.Content/.Input/.List/.Group/.Item/.Separator/.Empty for anything the flat groups array can't express (async loading, a custom filter function).
Subpath imports
Every component is imported by its own subpath (e.g. @vinyasa/overlay/dialog) — there is no root @vinyasa/overlay entry, so import { X } from '@vinyasa/overlay' fails to resolve. See "This package has no root export" above for why. Also available: /alert-dialog, /sheet, /tooltip, /popover, /hover-card, /dropdown-menu, /context-menu, /command-palette, /modal.
Development
From the repository root:
pnpm --filter @vinyasa/overlay build
pnpm --filter @vinyasa/overlay test
pnpm --filter @vinyasa/overlay lint
pnpm storybook # Overlay/*