Preview
Popup — large density
Backdrop-blur + drop-shadow + 16 px corner radius.
Popup is the frosted-glass surface used to wrap menus, date-pickers, and color-pickers when they need an anchor.
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add popup
Wire the tokens into your Tailwind v4 stylesheet:
/* app/globals.css */ @import 'tailwindcss'; @import '@oshon-ai/tokens/css'; @import '@oshon-ai/tokens/tailwind';
New here? Walk through the full setup — prereqs, theming, your first render.
Usage
Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.
'use client';
import { Popup } from '@oshon-ai/components';
export default function Example() {
return <Popup />;
}Large — 320px (Figma `Type=large`)
Popup title
Optional supporting description sits below the title.
<PopupStage>
<LargeHeaderDemo />
</PopupStage>Grid — compact, 193px (Figma `Type=grid`)
- List item one
- List item two
- List item three
<PopupStage>
<CompactGridDemo />
</PopupStage>Small — compact, 320px (Figma `Type=small`)
<PopupStage>
<CompactToggleDemo />
</PopupStage>All three Figma shapes — small / grid / large
- List item one
- List item two
- List item three
Popup title
Optional supporting description sits below the title.
<PopupStage>
<CompactToggleDemo />
<CompactGridDemo />
<LargeHeaderDemo />
</PopupStage>Inside a Popover — anchored + click-outside + ESC
<div
style={{
minHeight: '100vh',
background: PAGE_BG,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--oshon-space-6, 32px)',
fontFamily: 'var(--oshon-font-family, system-ui)',
}}
>
<Popover.Root>
<Popover.Trigger asChild>
<ButtonHug>Open popup</ButtonHug>
</Popover.Trigger>
<Popover.Portal>
{/*
`size="menu"` strips Popover.Content's own chrome (padding,
background, border, shadow) so the Popup's frosted-glass
surface is the only visible chrome. Click-outside, ESC,
collision flipping, and focus return all stay on Popover.
*/}
<Popover.Content size="menu" sideOffset={8}>
<Popup
density="large"
title="Popup title"
description="Optional supporting description sits below the title."
>
<BudgetCard
category="Item one"
remaining="Value"
spent={252}
limit={500}
tone="success"
/>
<BudgetCard
category="Item two"
remaining="Value"
spent={168}
limit={200}
tone="warning"
/>
</Popup>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>Size matrix — xs / s / m / l / mobile
Popup title
Optional supporting description sits below the title.
Popup title
Optional supporting description sits below the title.
Popup title
Optional supporting description sits below the title.
Popup title
Optional supporting description sits below the title.
Popup title
Optional supporting description sits below the title.
<div
style={{
minHeight: '100vh',
background: PAGE_BG,
padding: 'var(--oshon-space-6, 32px)',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))',
gap: 32,
fontFamily: 'var(--oshon-font-family, system-ui)',
}}
>
{SIZES.map((size) => (
<div key={size}>
<div
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.4px',
textTransform: 'uppercase',
color: 'var(--oshon-color-on-surface-muted)',
marginBottom: 12,
}}
>
size = {size}
</div>
<Popup
size={size}
density="large"
title="Popup title"
description="Optional supporting description sits below the title."
>
<BudgetCard
category="Item one"
remaining="Value"
spent={252}
limit={500}
tone="success"
/>
</Popup>
</div>
))}
</div>Density axis — compact vs large
- List item one
- List item two
- List item three
Popup title
Optional supporting description sits below the title.
- List item one
- List item two
- List item three
<PopupStage>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<span
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.4px',
textTransform: 'uppercase',
color: 'var(--oshon-color-on-surface-muted)',
}}
>
density = compact (8px outer padding)
</span>
<Popup>
<Card>
<CardHeader title="List title" count={3} onClose={() => {}} />
<CategoryList items={SAMPLE_CATEGORIES} />
</Card>
</Popup>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<span
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.4px',
textTransform: 'uppercase',
color: 'var(--oshon-color-on-surface-muted)',
}}
>
density = large (16px outer padding + popup header)
</span>
<Popup
density="large"
title="Popup title"
description="Optional supporting description sits below the title."
>
<Card>
<CategoryList items={SAMPLE_CATEGORIES} />
</Card>
</Popup>
</div>
</PopupStage>Styling
Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.
Passing Tailwind classes
Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.
<Popup className="ring-2 ring-offset-2 ring-blue-500" />
Data attributes
Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
--oshon-color-primary-700: var(--my-brand-color);
}Interactive states
Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.
:hover/hover:*— pointer hover:focus-visible/focus-visible:*— keyboard focus:active/active:*— pressed:disabled/disabled:*— set via thedisabledprop
Anatomy
The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.
titlePopup-level title (only renders when `density="large"`). Accepts a ReactNode but the canonical usage is a short string. Font is Lato SemiBold; size scales per the five-size axis (14/18 at xs through 20/26 at l).
descriptionPopup-level description (only renders when `density="large"`). Accepts a ReactNode rendered inside a `<p>`. Lato Regular 10/12 (12/16 at size=l). Use for the one-line context that explains the popup's scope (e.g. "Includes cart items.").
childrenBody content — typically one or more white inner cards. Popup ships chrome only; consumers compose the inner cards (with their own headers, action rows, lists, toggles, etc.) so the same chrome is reusable across share menus, summaries, filter previews, etc. See the playground for the three canonical Figma compositions.
Keyboard
Popup is presentation-only chrome — it does not own focus, click-outside, or ESC handling (those live in `Popover`). Renders a `<div>` by default (overridable to `aside` / `section` via `as`). When `density="large"` and `title` is provided, the title renders as the heading level chosen via `headingLevel` (default `h3` — popups are sub-page surfaces; PageHeader owns h1, Panel owns h2). Description is a `<p>` linked to the title via DOM order. Backdrop blur is decorative only — text contrast is computed against the white inner card, not the frosted layer.
Accessibility
Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.
- WCAG level
- 2.2 AA
- Screen readers tested
- VoiceOver (macOS), NVDA (Windows)
- Last axe audit
- 2026-04-27
Do / Don't
✓ Do
<Popup width={320}>
<Card>
<ToggleRow label="Allow abbreviations in search" checked onChange={…} />
</Card>
</Popup><Popup width={193}>
<Card>
<CardHeader title="Categories" count={3} onClose={…} />
<List items={['Spinach','Blueberries','Lettuce']} />
</Card>
</Popup><Popup density="large" title="Budget Status" description="Remaining budget values include all items currently in the cart." > <BudgetCard … /> <BudgetCard … /> </Popup>
<Popover.Root>
<Popover.Trigger asChild><ButtonHug>Show details</ButtonHug></Popover.Trigger>
<Popover.Portal>
<Popover.Content asChild>
<Popup density="large" title="Details">…</Popup>
</Popover.Content>
</Popover.Portal>
</Popover.Root>✗ Don't
<Popup onEscape={() => setOpen(false)}>…</Popup>Popup is presentation-only. Wrap it in `Popover` (or `Dialog` for modal popups) so the focus trap, click-outside, ESC, and trigger wiring stay in one tested place. Popup never owns these — that's how it stays portable into Popover, Dialog, Hovercard, Combobox, etc.
<Popup density="compact" title="Categories">…</Popup>
Compact density doesn't render the popup-level header (Figma `Type=small` and `Type=grid` push the header inside the inner card so it sits on the white surface, not the frosted layer). Either pass `density="large"` or render the header inside the inner card yourself.
<Popup className="backdrop-blur-0 bg-white">…</Popup>
The frosted-glass effect (16px backdrop-blur + 40% gray-300 wash + inner highlight + primary-tinted drop shadow) is the proprietary visual identity. Disabling it produces a generic shadow card that breaks the Oshon look. Override the inner card if you need a flat surface, not the popup chrome.
<Popup><Popup>…</Popup></Popup>
Two stacked frosted layers compound the backdrop-blur and produce a muddy, unreadable surface. Show one popup at a time; if you need a child surface, use a Tooltip or a secondary inline panel inside the existing popup.
Design rationale
Popup is the floating-surface chrome — backdrop-blur, frosted gray-300 wash, primary-tinted drop shadow, inner specular highlight, and 16px corner radius. The Figma source ships three named variants (Type=large, Type=small, Type=grid) but they all share the same outer chrome and only differ in (a) outer padding (8 vs 16) and (b) whether the popup itself renders a header or delegates the header to the inner card. This component captures that with the `density` prop. Width is caller-driven because the Figma cases sit at 320 (large/small) and 193 (grid); the size axis sets sensible defaults but consumers commonly pass `width` directly. The component is intentionally headless on positioning + close behaviour — those concerns live in `Popover` (Radix-backed) so the same Popup chrome can plug into Popover, Dialog, Hovercard, or any custom overlay without re-implementing focus trap / portal / collision detection. Per-slot className overrides + the `as` override keep every layout decision reachable from props so callers never need to fork the component.