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

Modal

Modal dialog.

Preview

Live preview
@oshon-ai/components
Default — m size, three-button footer
Form — real fields, submit state
Destructive confirmation
Long content — body scrolls, footer stays sticky
Notice — no footer
Sizes

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 modal

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

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

Figma variants

Small (Figma 2635:18312)352 × ≤ 280
Medium (Figma 2637:23407)900 × 600
Large (Figma 10078:2309)1344 × 928
tsx
{
    const variants: Array<{
      size: ModalSize;
      label: string;
      body: string;
      dim: string;
    }> = [
      {
        size: 'xs',
        label: 'Small (Figma 2635:18312)',
        body: 'Compact confirmation modal. Height hugs the content, capped at 280px.',
        dim: '352 × ≤ 280',
      },
      {
        size: 'm',
        label: 'Medium (Figma 2637:23407)',
        body: 'Mid-size form modal — 900 × 600 fixed. Use for focused single-purpose flows (edit profile, invite teammates, confirm checkout).',
        dim: '900 × 600',
      },
      {
        size: 'l',
        label: 'Large (Figma 10078:2309)',
        body: 'Full-surface modal — 1344 × 928 fixed. Use for data-dense workflows: detail pages, record editing, complex forms.',
        dim: '1344 × 928',
      },
    ];
    return (
      <div style={col}>
        <div style={row}>
          {variants.map(({ size, label, body, dim }) => (
            <div key={size} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
              <span style={caption}>
                <span style={captionStrong}>{label}</span>
                {dim}
              </span>
              <Modal
                size={size}
                title="Modal Title"
                required
                trigger={<ButtonHug>{`Open ${size}`}</ButtonHug>}
                actions={<FooterActions />}
              >
                {body}
              </Modal>
            </div>
          ))}
        </div>
      </div>
    );
  }

Size matrix interactive

size="xs"352 × ≤ 280 (Figma Small)
size="s"560 × auto (interpolated)
size="m"900 × 600 (Figma Medium)
size="l"1344 × 928 (Figma Large)
size="mobile"100vw - space-4 × auto
tsx
{
    const sizes: Array<{ size: ModalSize; note: string }> = [
      { size: 'xs', note: '352 × ≤ 280  (Figma Small)' },
      { size: 's', note: '560 × auto   (interpolated)' },
      { size: 'm', note: '900 × 600    (Figma Medium)' },
      { size: 'l', note: '1344 × 928   (Figma Large)' },
      { size: 'mobile', note: '100vw - space-4 × auto' },
    ];
    return (
      <div style={col}>
        <div style={row}>
          {sizes.map(({ size, note }) => (
            <div key={size} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
              <span style={caption}>
                <span style={captionStrong}>size="{size}"</span>
                {note}
              </span>
              <Modal
                size={size}
                title={`size="${size}"`}
                trigger={<ButtonHug size={size === 'mobile' ? 'm' : size}>{`Open ${size}`}</ButtonHug>}
                actions={<FooterActions />}
              >
                The same three-button trio works at every size. Long-form content
                scrolls inside the body — try resizing the viewport, pressing Escape,
                or clicking the close icon.
              </Modal>
            </div>
          ))}
        </div>
      </div>
    );
  }

With required helper

tsx
<Modal
      size="m"
      title="Edit profile"
      required
      trigger={<ButtonHug>Open with required helper</ButtonHug>}
      actions={<FooterActions />}
    >
      Required fields are marked with an asterisk. The modal&apos;s own
      &quot;*Required&quot; helper is a visual cue; screen readers ignore it
      and rely on field-level required attributes instead.
    </Modal>

Controlled working demo

tsx
{
    function Demo() {
      const [open, setOpen] = useState(false);
      const [dismissedVia, setDismissedVia] = useState<string | null>(null);
      return (
        <div style={col}>
          <ButtonHug
            onClick={() => {
              setDismissedVia(null);
              setOpen(true);
            }}
          >
            Open controlled modal
          </ButtonHug>
          {dismissedVia ? (
            <span style={caption}>
              <span style={captionStrong}>Last dismissed via:</span>
              {dismissedVia}
            </span>
          ) : null}
          <Modal
            open={open}
            onOpenChange={(next) => {
              setOpen(next);
              if (!next) setDismissedVia('onOpenChange(false)');
            }}
            size="m"
            title="Confirm deletion"
            actions={
              <>
                <ButtonHug tier="tertiary" onClick={() => setOpen(false)}>
                  Cancel
                </ButtonHug>
                <ButtonHug
                  tier="primary"
                  onClick={() => {
                    setDismissedVia('Delete action');
                    setOpen(false);
                  }}
                >
                  Delete
                </ButtonHug>
              </>
            }
          >
            This action permanently removes the record and cannot be undone.
            Open this from code by flipping a boolean — bind `open` +
            `onOpenChange` and the component handles focus trap, escape-to-close,
            and outside-click for you.
          </Modal>
        </div>
      );
    }
    return <Demo />;
  }

Permission gated interactive

tsx
{
    function Demo() {
      const [canView, setCanView] = useState(true);
      return (
        <div style={col}>
          <label style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
            <input
              type="checkbox"
              checked={canView}
              onChange={(e) => setCanView(e.target.checked)}
            />
            <span style={caption}>`permissions.can` returns {String(canView)}</span>
          </label>
          <Modal
            size="m"
            title="Billing history"
            permissions={{ can: () => canView, mode: 'hidden' }}
            resource="record:billing-history"
            trigger={<ButtonHug>Open billing</ButtonHug>}
            actions={<FooterActions />}
          >
            When `canView` is false, the modal renders nothing when triggered
            and the primitive emits a `permission:denied` audit event to the
            ambient `AuditProvider`. Toggle the checkbox and reopen to verify.
          </Modal>
        </div>
      );
    }
    return <Demo />;
  }
tsx
<Modal
      size="xs"
      title="Backup complete"
      trigger={<ButtonHug>Show notice</ButtonHug>}
    >
      Your data has been backed up to encrypted cold storage. No further action
      is required — you can close this dialog.
    </Modal>

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

Modal title — accepts a ReactNode, rendered as `Dialog.Title` (wired to `aria-labelledby`).

trigger

Optional element that opens the modal — forwarded to `Dialog.Trigger` via `asChild`. Omit when controlling via `open`/`onOpenChange`.

description

Optional accessible description — rendered hidden (sr-only). Use when the body text is visual-only and needs screen-reader narration, or to describe the purpose of the modal to assistive tech.

children

Body content — any ReactNode. Scrolls when it exceeds the surface height (Medium and Large are fixed-height; Small, S, and Mobile cap via max-height).

actions

Footer actions — typically 2–3 `ButtonHug` elements (tertiary / secondary / primary). Right-aligned with an 8px gap. Omit for notice-style modals without actions.

Keyboard

Esc: close. Tab/Shift+Tab: focus trap cycles the header close button, body interactive controls, and footer actions. Enter: activate focused control. Behavior inherited from @oshon-ai/primitives/dialog (Radix-backed). Close icon-button carries `aria-label` = `closeLabel` (default "Close"); the required helper is marked `aria-hidden` because it is decorative — form fields inside the body own their own required semantics.

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

Do / Don't

✓ Do

Uncontrolled with a trigger
<Modal
  title="Delete project"
  trigger={<ButtonHug tier="primary">Delete…</ButtonHug>}
  actions={<>
    <ButtonHug tier="tertiary">Cancel</ButtonHug>
    <ButtonHug tier="primary">Delete</ButtonHug>
  </>}
>
  This action cannot be undone.
</Modal>
Controlled Medium with required helper
const [open, setOpen] = useState(false);
return (
  <Modal
    open={open}
    onOpenChange={setOpen}
    size="m"
    title="Edit profile"
    required
    actions={<>
      <ButtonHug tier="tertiary" onClick={() => setOpen(false)}>Cancel</ButtonHug>
      <ButtonHug tier="primary" onClick={save}>Save</ButtonHug>
    </>}
  >
    {/* form fields */}
  </Modal>
);
Large, permission-gated
<Modal
  size="l"
  title="Billing history"
  permissions={{ can: () => canViewBilling }}
  resource="record:billing-history"
>
  <BillingTable />
</Modal>

✗ Don't

Putting the close button inside the body
<Modal title="…">
  <button>Close</button>
</Modal>

The close affordance is structural and lives in the header for every Figma variant; duplicating it in the body breaks the consistent Oshon modal contract and creates two competing dismiss paths.

Using Modal for non-modal affordances
<Modal title="Hint">Tooltip-style hover hint</Modal>

Modal takes focus, traps it, and scrolls the background. For non-blocking hints use Tooltip; for inline dismissible notices use Banner; for anchored surfaces use Popover.

Overriding width via className
<Modal className="w-[700px]" …/>

The `size` prop ("xs" | "s" | "m" | "l" | "mobile") owns the width/height contract per the five-size design axis. Hand-picked widths violate principle #13 (preserve Oshon visual identity) and won't scale with the Figma refactors. If you need a width that isn't in the size axis, open an issue to negotiate the new size.

Design rationale

Principle #8 — one manifest, one surface — pushed Modal to be flat-props with a fixed layout (header | body | footer) rather than a compound. The Figma source ships Small/Medium/Large as size variants of one component, so the API collapses them to a single `size` prop. We wrap the primitive Dialog directly (not the styled Dialog compound) so Modal owns its width/padding/radius contract without class-merge conflicts. Radius is held as an inline 30px override because the closest token (`--oshon-radius-2xl` = 16px) is too tight for Figma parity; a follow-up can extend the radius scale if another component needs 30px. Every color, space, motion, and font value flows through `@oshon-ai/tokens` (principle #6). Permissions and audit events come free via the primitive.