Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
ContainmentUpdatedFreeWCAG 2.2 AA

Popup

Popup — Large / Grid / Small — 3 variants.

Preview

Live preview
@oshon-ai/components

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
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add popup

Wire the tokens into your Tailwind v4 stylesheet:

css
/* 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.

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

Item oneValue
252 of 500
Item twoValue
168 of 200
Item threeValue
314 of 300
tsx
<PopupStage>
      <LargeHeaderDemo />
    </PopupStage>

Grid — compact, 193px (Figma `Type=grid`)

List title3
  • List item one
  • List item two
  • List item three
tsx
<PopupStage>
      <CompactGridDemo />
    </PopupStage>

Small — compact, 320px (Figma `Type=small`)

Setting label
tsx
<PopupStage>
      <CompactToggleDemo />
    </PopupStage>

All three Figma shapes — small / grid / large

Setting label
List title3
  • List item one
  • List item two
  • List item three

Popup title

Optional supporting description sits below the title.

Item oneValue
252 of 500
Item twoValue
168 of 200
Item threeValue
314 of 300
tsx
<PopupStage>
      <CompactToggleDemo />
      <CompactGridDemo />
      <LargeHeaderDemo />
    </PopupStage>

Inside a Popover — anchored + click-outside + ESC

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

size = xs

Popup title

Optional supporting description sits below the title.

Item oneValue
252 of 500
size = s

Popup title

Optional supporting description sits below the title.

Item oneValue
252 of 500
size = m

Popup title

Optional supporting description sits below the title.

Item oneValue
252 of 500
size = l

Popup title

Optional supporting description sits below the title.

Item oneValue
252 of 500
size = mobile

Popup title

Optional supporting description sits below the title.

Item oneValue
252 of 500
tsx
<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

density = compact (8px outer padding)
List title3
  • List item one
  • List item two
  • List item three
density = large (16px outer padding + popup header)

Popup title

Optional supporting description sits below the title.

  • List item one
  • List item two
  • List item three
tsx
<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.

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

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* 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 the disabled prop

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.

title

Popup-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).

description

Popup-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.").

children

Body 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

Compact — single inner card with toggle (Figma Type=small)
<Popup width={320}>
  <Card>
    <ToggleRow label="Allow abbreviations in search" checked onChange={…} />
  </Card>
</Popup>
Compact — narrow grid with header + list (Figma Type=grid)
<Popup width={193}>
  <Card>
    <CardHeader title="Categories" count={3} onClose={…} />
    <List items={['Spinach','Blueberries','Lettuce']} />
  </Card>
</Popup>
Large — popup-level header + multiple cards (Figma Type=large)
<Popup
  density="large"
  title="Budget Status"
  description="Remaining budget values include all items currently in the cart."
>
  <BudgetCard … />
  <BudgetCard … />
</Popup>
Inside a Popover for positioning + click-outside
<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

Wiring focus trap / ESC into Popup directly
<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.

Setting `density="compact"` AND passing `title`
<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.

Reaching past the chrome to override the backdrop blur
<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.

Stacking popups inside popups
<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.