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

Dialog

Lower-level dialog primitive — Modal is built on top.

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 dialog

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

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

Default

tsx
<Dialog.Root defaultOpen={false}>
      <Dialog.Trigger>Open dialog</Dialog.Trigger>
      <Dialog.Content size="m">
        <Dialog.Title>Dialog title</Dialog.Title>
        <Dialog.Description>Dialog body content.</Dialog.Description>
        <Dialog.Close>Close</Dialog.Close>
      </Dialog.Content>
    </Dialog.Root>

Size matrix

tsx
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
      <div key="xs" data-story-size="xs">
        <Dialog.Root defaultOpen={false}>
          <Dialog.Trigger>Open dialog</Dialog.Trigger>
          <Dialog.Content size="xs">
            <Dialog.Title>Dialog title</Dialog.Title>
            <Dialog.Description>Dialog body content.</Dialog.Description>
            <Dialog.Close>Close</Dialog.Close>
          </Dialog.Content>
        </Dialog.Root>
      </div>
      <div key="s" data-story-size="s">
        <Dialog.Root defaultOpen={false}>
          <Dialog.Trigger>Open dialog</Dialog.Trigger>
          <Dialog.Content size="s">
            <Dialog.Title>Dialog title</Dialog.Title>
            <Dialog.Description>Dialog body content.</Dialog.Description>
            <Dialog.Close>Close</Dialog.Close>
          </Dialog.Content>
        </Dialog.Root>
      </div>
      <div key="m" data-story-size="m">
        <Dialog.Root defaultOpen={false}>
          <Dialog.Trigger>Open dialog</Dialog.Trigger>
          <Dialog.Content size="m">
            <Dialog.Title>Dialog title</Dialog.Title>
            <Dialog.Description>Dialog body content.</Dialog.Description>
            <Dialog.Close>Close</Dialog.Close>
          </Dialog.Content>
        </Dialog.Root>
      </div>
      <div key="l" data-story-size="l">
        <Dialog.Root defaultOpen={false}>
          <Dialog.Trigger>Open dialog</Dialog.Trigger>
          <Dialog.Content size="l">
            <Dialog.Title>Dialog title</Dialog.Title>
            <Dialog.Description>Dialog body content.</Dialog.Description>
            <Dialog.Close>Close</Dialog.Close>
          </Dialog.Content>
        </Dialog.Root>
      </div>
      <div key="mobile" data-story-size="mobile">
        <Dialog.Root defaultOpen={false}>
          <Dialog.Trigger>Open dialog</Dialog.Trigger>
          <Dialog.Content size="mobile">
            <Dialog.Title>Dialog title</Dialog.Title>
            <Dialog.Description>Dialog body content.</Dialog.Description>
            <Dialog.Close>Close</Dialog.Close>
          </Dialog.Content>
        </Dialog.Root>
      </div>
    </div>

Permission denied

tsx
<Dialog.Root defaultOpen={false} permissions={{ can: () => false }}>
      <Dialog.Trigger>Open dialog</Dialog.Trigger>
      <Dialog.Content size="m">
        <Dialog.Title>Dialog title</Dialog.Title>
        <Dialog.Description>Dialog body content.</Dialog.Description>
        <Dialog.Close>Close</Dialog.Close>
      </Dialog.Content>
    </Dialog.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
<Dialog
  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

Element that opens the dialog. Native <button> by default; pass `asChild` (Radix pass-through) to compose on top of another control.

Overlay

Full-viewport scrim rendered behind the content.

Content

The modal surface itself. Takes `size="xs|s|m|l|mobile"` to scale max-width.

Title

Accessible dialog title — wired to `aria-labelledby` by Radix.

Description

Accessible dialog description — wired to `aria-describedby` by Radix.

Close

Styled close button. Icon-only close buttons receive a token-driven aria-label via the primitive i18n layer.

Keyboard

Esc: close. Tab/Shift+Tab: focus trap cycles interactive content. Enter: activate focused control. (Behavior inherited from @radix-ui/react-dialog via @oshon-ai/primitives/dialog.)

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
<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content size="m">
      <Dialog.Title>Confirm</Dialog.Title>
      <Dialog.Description>Are you sure?</Dialog.Description>
      <Dialog.Close>Cancel</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>
Sized content (large)
<Dialog.Content size="l">…</Dialog.Content>
Permission-gated
<Dialog.Root permissions={{ can: () => canDelete }} resource="record:delete-confirmation">
  …
</Dialog.Root>

✗ Don't

Using Dialog for non-modal affordances
<Dialog.Root>tooltip-style hover hint</Dialog.Root>

Dialog takes focus and scrolls the background; it is for modal interactions only. Use Tooltip or Popover for non-blocking hints.

Rendering a Dialog.Content outside Dialog.Portal
<Dialog.Root><Dialog.Content>…</Dialog.Content></Dialog.Root>

Content must live inside Portal so it renders at the document root — otherwise stacking contexts + overflow: hidden ancestors will clip it and break focus management.

Design rationale

Phase 4a anchor for overlays. Proves the visual-layer template for compound components (Root/Trigger/Portal/Overlay/Content/Title/Description/Close) where every piece carries its own tokenized class string. Animations use inline @keyframes defined once per Portal instance; reduced-motion is honored by the ADR-001 fallback (motion tokens go to 0ms under prefers-reduced-motion). See ADR-001, ADR-002, ADR-003.