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 toggle
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 { Toggle } from '@oshon-ai/components';
export default function Example() {
return <Toggle />;
}Default
<Toggle size="m" aria-label="Demo toggle" />
Size matrix
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
<div key="xs" data-story-size="xs">
<Toggle size="xs" aria-label="Demo toggle" />
</div>
<div key="s" data-story-size="s">
<Toggle size="s" aria-label="Demo toggle" />
</div>
<div key="m" data-story-size="m">
<Toggle size="m" aria-label="Demo toggle" />
</div>
<div key="l" data-story-size="l">
<Toggle size="l" aria-label="Demo toggle" />
</div>
<div key="mobile" data-story-size="mobile">
<Toggle size="mobile" aria-label="Demo toggle" />
</div>
</div>Permission denied
<Toggle size="m" permissions={{ can: () => false }} aria-label="Demo toggle" />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.
<Toggle 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.
thumbAria-hidden <span data-oshon-slot="thumb"> rendered by the visual wrapper itself. The consumer does not supply the thumb — the track+thumb structure is owned by the component. The accessible name comes from `aria-label` on the wrapper or an external <label htmlFor>.
Keyboard
Tab/Shift+Tab: focus. Space/Enter: flip (native button activation). role="switch" + aria-checked synchronised by the primitive.
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
<Toggle aria-label="Notifications" />
<Toggle aria-label="Sync" checked={syncOn} onCheckedChange={setSyncOn} /><Toggle aria-label="Publish" resource="settings:publish" permissions={{ can: () => canPublish }} /><Toggle size="l" aria-label="Dark mode" defaultChecked />
✗ Don't
<Toggle />
The visible UI is a track+thumb with no text — screen reader users would hear only "switch, off". Always pass aria-label (or wire an external <label htmlFor>).
<Toggle>Enable sync</Toggle>
The visual wrapper renders its own aria-hidden thumb in the primitive's children slot. A consumer-supplied label there would be hidden from AT and would also visually collide with the thumb. Use aria-label or an external <label>.
<button role="switch" aria-checked={on} onClick={flip}>…</button>Loses permission gating, canonical audit, the Oshon track+thumb visual, and the data-oshon-size hook. Always use <Toggle>.
Design rationale
The on-state color is primary-900 to match the Button hug variant — together they form the two brand-emphasis affordances in a form. The track is the interactive <button> itself (not a wrapper <label>) so focus, disabled, and click semantics live on a single element. Thumb translate distances come entirely from --oshon-space-* tokens so white-labeling a denser theme scales everything proportionally.