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 badge
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 { Badge } from '@oshon-ai/components';
export default function Example() {
return <Badge />;
}Status — desktop
Success
In-progress
New
End Point
Negative
AI Generated
<div style={col}>
<div style={row}>
{STATUSES.map((s) => (
<div
key={s.state}
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
>
<Badge state={s.state}>{s.label}</Badge>
<div style={{ ...caption, fontSize: '11px' }}>
<strong style={{ color: 'var(--oshon-color-neutral-900)' }}>
{s.family}
</strong>
<br />
{s.tone}
</div>
</div>
))}
</div>
</div>Status — mobile
Success
In-progress
New
End Point
Negative
AI Generated
<div style={col}>
<div style={row}>
{STATUSES.map((s) => (
<div
key={s.state}
style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}
>
<Badge state={s.state} size="mobile">
{s.label}
</Badge>
<div style={{ ...caption, fontSize: '11px' }}>
<strong style={{ color: 'var(--oshon-color-neutral-900)' }}>
{s.family}
</strong>
<br />
{s.tone}
</div>
</div>
))}
</div>
</div>Dot — notification counter
<div style={col}>
<div style={row}>
{STATUSES.map((s) => (
<BadgeDot key={s.state} state={s.state} aria-label={`${s.label} · 1`}>
1
</BadgeDot>
))}
</div>
<div style={row}>
<BadgeDot state="error">3</BadgeDot>
<BadgeDot state="error" size="l">12</BadgeDot>
<BadgeDot state="error" size="m">99+</BadgeDot>
<BadgeDot state="error" size="s">1k+</BadgeDot>
</div>
</div>Opens workflow — popover composition
{
const [currentStep, setCurrentStep] = useState<WorkflowStep>('Adjusted');
const [open, setOpen] = useState(false);
const state = stateFor(currentStep);
return (
<div style={col}>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type="button"
aria-label={`Current order state: ${currentStep}. Click to edit.`}
style={{
display: 'inline-flex',
alignItems: 'center',
border: 0,
padding: 0,
background: 'transparent',
cursor: 'pointer',
borderRadius: '4px',
}}
>
<Badge
state={state}
size="mobile"
trailing={<ChevronDownIcon />}
>
{currentStep}
</Badge>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
aria-label="Order workflow"
sideOffset={8}
align="start"
>
<WorkflowBreadcrumb
currentStep={currentStep}
onSelectStep={(s) => {
setCurrentStep(s);
setOpen(false);
}}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
);
}Size matrix — QA grid
| success | warning | neutral | info | error | plum | |
|---|---|---|---|---|---|---|
| xs | Confirmed | Adjusted | Drafted | Received | Canceled | Suggested |
| s | Confirmed | Adjusted | Drafted | Received | Canceled | Suggested |
| m | Confirmed | Adjusted | Drafted | Received | Canceled | Suggested |
| l | Confirmed | Adjusted | Drafted | Received | Canceled | Suggested |
| mobile | Confirmed | Adjusted | Drafted | Received | Canceled | Suggested |
| success | warning | neutral | info | error | plum | |
|---|---|---|---|---|---|---|
| xs | 1 | 1 | 1 | 1 | 1 | 1 |
| s | 1 | 1 | 1 | 1 | 1 | 1 |
| m | 1 | 1 | 1 | 1 | 1 | 1 |
| l | 1 | 1 | 1 | 1 | 1 | 1 |
| mobile | 1 | 1 | 1 | 1 | 1 | 1 |
<div style={{ ...col, gap: '1.25rem' }}>
<table
style={{
borderCollapse: 'separate',
borderSpacing: '12px',
fontFamily: 'var(--oshon-font-family, system-ui)',
}}
>
<thead>
<tr>
<th style={caption}> </th>
{STATUSES.map((s) => (
<th key={s.state} style={{ ...caption, textAlign: 'left' }}>
{s.state}
</th>
))}
</tr>
</thead>
<tbody>
{SIZES.map((size) => (
<tr key={size}>
<td style={caption}>{size}</td>
{STATUSES.map((s) => (
<td key={s.state}>
<Badge state={s.state} size={size}>
{s.label}
</Badge>
</td>
))}
</tr>
))}
</tbody>
</table>
<table
style={{
borderCollapse: 'separate',
borderSpacing: '12px',
fontFamily: 'var(--oshon-font-family, system-ui)',
}}
>
<thead>
<tr>
<th style={caption}> </th>
{STATUSES.map((s) => (
<th key={s.state} style={{ ...caption, textAlign: 'left' }}>
{s.state}
</th>
))}
</tr>
</thead>
<tbody>
{SIZES.map((size) => (
<tr key={size}>
<td style={caption}>{size}</td>
{STATUSES.map((s) => (
<td key={s.state}>
<BadgeDot state={s.state} size={size}>
1
</BadgeDot>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>Workflow — live demo
{
const [currentStep, setCurrentStep] = useState<WorkflowStep>('Created');
const state = stateFor(currentStep);
return (
<div style={{ ...col, gap: '1.5rem', maxWidth: '780px' }}>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<span style={{ ...caption, minWidth: '64px' }}>Desktop</span>
<Badge state={state}>{currentStep}</Badge>
</div>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<span style={{ ...caption, minWidth: '64px' }}>Mobile</span>
<Badge state={state} size="mobile">
{currentStep}
</Badge>
</div>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<span style={{ ...caption, minWidth: '64px' }}>Counter</span>
<BadgeDot state={state}>
{WORKFLOW_STEPS.indexOf(currentStep) + 1}
</BadgeDot>
<span style={caption}>
(step {WORKFLOW_STEPS.indexOf(currentStep) + 1} of{' '}
{WORKFLOW_STEPS.length})
</span>
</div>
<WorkflowBreadcrumb
currentStep={currentStep}
onSelectStep={setCurrentStep}
/>
<div
style={{
...caption,
marginTop: '8px',
fontStyle: 'italic',
}}
>
Current state → <strong>{state}</strong> · current step →{' '}
<strong>{currentStep}</strong>
</div>
</div>
);
}API
Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.
| Prop | Type | Default | Description |
|---|---|---|---|
children* | ReactNode | — | Pill label. Rendered uppercase per the Figma spec — pass the label in its natural case and the badge applies `text-transform`. |
className | string | — | Additional classes merged after the component's default classes. |
leading | ReactNode | — | Optional leading glyph. Replaces the default 8×8 indicator dot. Pass a 16×16 icon element when combining the badge with a custom glyph (e.g. the "Created" check icon shown in the Figma workflow row). |
showIndicator | boolean | | Set `false` to hide the leading 8×8 indicator dot (e.g. when the badge carries its own leading glyph passed as `leading`). Default `true`. |
size | enum | m | Visual size. Default `'m'` (desktop Figma). |
state | enum | neutral | Six canonical status palettes. Drives `data-state` + the surface / indicator / text tokens. Default `'neutral'`. |
trailing | ReactNode | — | Optional trailing glyph. Figma authors this as a 16×16 chevron when the badge is used as the trigger for an "opens workflow" popover. Ship the icon here and wrap the badge in a `Popover.Trigger` — Badge itself stays a non-interactive `<span>` so the composition is valid HTML. |
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.
<Badge 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.
childrenBadge: the pill label, rendered uppercase (pass in natural case). BadgeDot: the count label (number or short string).
leadingBadge only. Replaces the default 8×8 indicator dot with a 16×16 custom glyph — useful when the row design pairs a check / status icon with the text (see Figma workflow row, nodes 8732:1010 – 8732:1013).
trailingBadge only. Optional 16×16 trailing glyph. Figma authors a chevron here when the pill is used as a Popover trigger ("Badge opens Workflow", node 8732:1029). Badge stays a `<span>`; compose it inside the trigger element and let the trigger own focus / aria-expanded / audit.
Keyboard
Non-interactive. Badge + BadgeDot receive no focus and no keyboard bindings. When composing Badge as a Popover trigger the parent element owns all interactive concerns (Enter / Space / aria-expanded). Pass `aria-label` to BadgeDot when the visible count needs context ("42 unread messages").
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-21
Do / Don't
✓ Do
<Badge state="success">Confirmed</Badge> <Badge state="warning">Adjusted</Badge> <Badge state="neutral">Drafted</Badge> <Badge state="info">Received</Badge> <Badge state="error">Canceled</Badge> <Badge state="plum">Suggested</Badge>
<Badge state="warning" size="mobile">Adjusted</Badge>
<Popover>
<Popover.Trigger asChild>
<button type="button" aria-label="Edit workflow">
<Badge state="warning" trailing={<ChevronDownIcon />}>Adjusted</Badge>
</button>
</Popover.Trigger>
<Popover.Content>…workflow breadcrumb…</Popover.Content>
</Popover><BadgeDot state="error" size="s">3</BadgeDot>
✗ Don't
<Badge onClick={open} state="warning">Adjusted</Badge>Badge is a non-interactive <span> by design (Figma spec note: "Status badges CAN have a tooltip on hover, but they are not actionable"). When you need interaction, wrap Badge in a Popover.Trigger or a <button> — the trigger owns focus, aria-expanded, and audit; Badge stays pure visual. Putting `onClick` on the `<span>` ships an accessibility failure (no focus ring, no keyboard activation).
<Badge className="bg-purple-600 text-white">Draft</Badge>
Breaks white-labeling (principle #6). Every palette flows through @oshon-ai/tokens so `applyTheme({ primarySeed })` retints the whole system in one DOM write. If you need a palette that isn't in the six-state union, extend the manifest + add the token mapping in `badge-shared.tsx` — never reach for a one-off class.
<BadgeDot state="error">{inboxCount}</BadgeDot>The circle is a fixed width at every size. Unbounded counts blow the dot sideways and break the nav-icon alignment. Cap the value upstream ("99+", "1k+") and pass the formatted string; BadgeDot centers whatever you give it but it does not format for you.
Design rationale
Second RSC-safe leaf after CounterChip. Badge is a pure <span> so the pill can live inside Server Components (table cells, list rows, header status bars) without a client boundary. The "badge opens workflow" pattern from the Figma spec composes Badge inside a Popover.Trigger — the trigger element owns interaction semantics; Badge stays presentational. Six states map 1:1 to the Oshon semantic color ramps (success/warning/neutral/info/error/plum) so a white-label retheme via `applyTheme()` flows through every badge in a single DOM write. Five sizes anchor at the two Figma-authored endpoints (m = desktop 10/12, mobile = 12/14) with conservative xs/s/l interpolations along the same track — the immutable five-size contract (rule #2) needs full coverage even when Figma only authors the two practical end-points. BadgeDot keeps the pill's typography discipline (Lato Bold, tabular-nums) so digits don't reflow when a live counter ticks 9 → 10.