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

Popover

Anchored overlay with arrow — rich content variant of Tooltip.

Preview

Live preview
@oshon-ai/components

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 popover

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 { Popover } from '@oshon-ai/components';

export default function Example() {
  return <Popover />;
}

Default

tsx
<Popover.Root>
      <Popover.Trigger>Open popover</Popover.Trigger>
      <Popover.Content size="m">Popover content.</Popover.Content>
    </Popover.Root>

Size matrix

tsx
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
      <div key="xs" data-story-size="xs">
        <Popover.Root>
          <Popover.Trigger>Open popover</Popover.Trigger>
          <Popover.Content size="xs">Popover content.</Popover.Content>
        </Popover.Root>
      </div>
      <div key="s" data-story-size="s">
        <Popover.Root>
          <Popover.Trigger>Open popover</Popover.Trigger>
          <Popover.Content size="s">Popover content.</Popover.Content>
        </Popover.Root>
      </div>
      <div key="m" data-story-size="m">
        <Popover.Root>
          <Popover.Trigger>Open popover</Popover.Trigger>
          <Popover.Content size="m">Popover content.</Popover.Content>
        </Popover.Root>
      </div>
      <div key="l" data-story-size="l">
        <Popover.Root>
          <Popover.Trigger>Open popover</Popover.Trigger>
          <Popover.Content size="l">Popover content.</Popover.Content>
        </Popover.Root>
      </div>
      <div key="mobile" data-story-size="mobile">
        <Popover.Root>
          <Popover.Trigger>Open popover</Popover.Trigger>
          <Popover.Content size="mobile">Popover content.</Popover.Content>
        </Popover.Root>
      </div>
    </div>

Permission denied

tsx
<Popover.Root permissions={{ can: () => false }}>
      <Popover.Trigger>Open popover</Popover.Trigger>
      <Popover.Content size="m">Popover content.</Popover.Content>
    </Popover.Root>

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

Trigger

The clickable element that opens the popover. Use `asChild` to forward the trigger contract to a <Button> or custom element so it inherits its own visual state.

Anchor

Optional alternate positioning target. When present, the popover positions relative to the Anchor instead of the Trigger. Useful for context menus anchored to a cell, a cursor position, or a row.

Content

The popover surface. Hosts arbitrary children — action lists, mini-forms, pickers. Accepts `size` to scale max-width + padding.

Close

Optional close button; clicking it closes the popover. Styled like Dialog.Close so popover/dialog footers read consistently.

Arrow

Optional pointer arrow that tracks the popover's chosen side. Fills with the raised-surface color so it visually continues Content chrome.

Keyboard

Tab into Trigger; Enter/Space opens. ESC closes. Focus is returned to the Trigger on close. Tab inside Content walks through interactive children (non-modal: focus can leave the popover). Click-outside closes. role="dialog" on Content.

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

Do / Don't

✓ Do

Basic (asChild trigger)
<Popover.Root>
  <Popover.Trigger asChild><Button>Filters</Button></Popover.Trigger>
  <Popover.Portal>
    <Popover.Content>
      <p>Filter by role, status, date…</p>
    </Popover.Content>
  </Popover.Portal>
</Popover.Root>
Sized content
<Popover.Content size="l"><p>Long-form help…</p></Popover.Content>
Gated by permission
<Popover.Root permissions={{ can: () => canViewQuickActions }} resource="record:quick-actions">…</Popover.Root>
With close + arrow
<Popover.Content>
  <p>Edit username</p>
  <Popover.Close>Done</Popover.Close>
  <Popover.Arrow />
</Popover.Content>

✗ Don't

Nesting a full form flow
<Popover.Content><SubmitApplicationForm /></Popover.Content>

Popovers are non-modal and can be dismissed by outside clicks. Flows that require completion belong in <Dialog>, not <Popover>. Use Popover for quick edits and mini-forms only.

Rendering Content outside the Portal
<Popover.Root>
  <Popover.Content>…</Popover.Content>
</Popover.Root>

Breaks layering + collision detection. Always wrap Content in Popover.Portal so it escapes the parent stacking context.

Design rationale

Raised-surface chrome (matching Dialog) rather than a dark chip (tooltip) because popovers host interactive content, not read-only hints. Side-specific entrance animations are keyed off Radix `data-side` so the popover appears to slide in from the trigger, reinforcing the spatial relationship between trigger + surface. Inline keyframes rather than CSS module (same rationale as Dialog, ADR-001) — popovers ship with a fourth cardinal-direction keyframe set because they have no fixed direction (unlike centered modals).