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

Badge

Status indicator badge.

Preview

Live preview
@oshon-ai/components
Default
ConfirmedAdjustedDraftedReceivedCanceledSuggested3
Sizes
xsConfirmedAdjustedDraftedReceivedCanceledSuggested3
sConfirmedAdjustedDraftedReceivedCanceledSuggested3
mConfirmedAdjustedDraftedReceivedCanceledSuggested3
lConfirmedAdjustedDraftedReceivedCanceledSuggested3
mobileConfirmedAdjustedDraftedReceivedCanceledSuggested3
BadgeDot — all states
2512399+7

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 badge

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

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

Status — desktop

Confirmed
Green
Success
Adjusted
Yellow
In-progress
Drafted
Gray
New
Received
Blue
End Point
Canceled
Red
Negative
Suggested
Plum
AI Generated
tsx
<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

Confirmed
Green
Success
Adjusted
Yellow
In-progress
Drafted
Gray
New
Received
Blue
End Point
Canceled
Red
Negative
Suggested
Plum
AI Generated
tsx
<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

111111
31299+1k+
tsx
<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

tsx
{
    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

successwarningneutralinfoerrorplum
xsConfirmedAdjustedDraftedReceivedCanceledSuggested
sConfirmedAdjustedDraftedReceivedCanceledSuggested
mConfirmedAdjustedDraftedReceivedCanceledSuggested
lConfirmedAdjustedDraftedReceivedCanceledSuggested
mobileConfirmedAdjustedDraftedReceivedCanceledSuggested
successwarningneutralinfoerrorplum
xs111111
s111111
m111111
l111111
mobile111111
tsx
<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

DesktopCreated
MobileCreated
Counter1(step 1 of 7)
Current state → neutral · current step → Created
tsx
{
    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.

PropTypeDefaultDescription
children*ReactNodePill label. Rendered uppercase per the Figma spec — pass the label in its natural case and the badge applies `text-transform`.
classNamestringAdditional classes merged after the component's default classes.
leadingReactNodeOptional 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).
showIndicatorbooleanSet `false` to hide the leading 8×8 indicator dot (e.g. when the badge carries its own leading glyph passed as `leading`). Default `true`.
sizeenummVisual size. Default `'m'` (desktop Figma).
stateenumneutralSix canonical status palettes. Drives `data-state` + the surface / indicator / text tokens. Default `'neutral'`.
trailingReactNodeOptional 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.

tsx
<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.

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.

children

Badge: the pill label, rendered uppercase (pass in natural case). BadgeDot: the count label (number or short string).

leading

Badge 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).

trailing

Badge 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

Status row (six default states)
<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>
Mobile size (touch)
<Badge state="warning" size="mobile">Adjusted</Badge>
Badge opens Workflow (composed inside a Popover trigger)
<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>
Notification dot (small inbox counter)
<BadgeDot state="error" size="s">3</BadgeDot>

✗ Don't

Making Badge itself a button
<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).

Hardcoded color override
<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 with unbounded count
<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.