Preview
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 modal
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 { Modal } from '@oshon-ai/components';
export default function Example() {
return <Modal />;
}Figma variants
{
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
{
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
<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's own
"*Required" helper is a visual cue; screen readers ignore it
and rely on field-level required attributes instead.
</Modal>Controlled working demo
{
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
{
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 />;
}Notice no footer
<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.
<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.
| 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.
titleModal title — accepts a ReactNode, rendered as `Dialog.Title` (wired to `aria-labelledby`).
triggerOptional element that opens the modal — forwarded to `Dialog.Trigger` via `asChild`. Omit when controlling via `open`/`onOpenChange`.
descriptionOptional 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.
childrenBody content — any ReactNode. Scrolls when it exceeds the surface height (Medium and Large are fixed-height; Small, S, and Mobile cap via max-height).
actionsFooter 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
<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>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>
);<Modal
size="l"
title="Billing history"
permissions={{ can: () => canViewBilling }}
resource="record:billing-history"
>
<BillingTable />
</Modal>✗ Don't
<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.
<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.
<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.