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

SelectionChip

Multi-select chip (checkbox behavior).

Preview

Live preview
@oshon-ai/components

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 selectionchip

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

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

Multi-select filters

Active: Bug, Feature
tsx
<SelectionChipPlayground />

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*ReactNodeChip label.
checkedbooleanChecked state. Drives `aria-checked` + the Blue Stone 200 surface + the trailing check.
classNamestringAdditional classes merged after the component's default classes.
disabledbooleanDisable the button. Renders with `aria-disabled="true"` and the native `disabled` attribute so screen readers and keyboard users get consistent signal. When `disabled`, `onClick` does not fire and no audit event is emitted.
loadingbooleanIn-flight state. Sets `aria-busy="true"` + `disabled` so assistive tech announces the button as unavailable while the action resolves. The visual layer renders a spinner on top; the primitive itself does not.
permissionAttrsRecord<string, unknown>Attribute bag forwarded to `permissions.can(..., attrs)` for attribute-based access control (row ID, tenant, owner). Primitives never invent values here — the consumer supplies them.
permissionsPartial<PermissionContext>Per-instance permission override. Merges over the ambient `PermissionContext`. Example: `permissions={{ mode: 'hidden' }}` switches this one instance to render-as-null when denied.
resourcestringResource name passed to `permissions.can(action, resource, attrs)`. Default `'button'`. Consumers override for specific semantics, e.g. `resource="row:edit"` for a row-level action button.
sizeenummVisual size. Default `'m'` — maps to Figma "Size=desktop".
typeenumNative `type` attribute. Defaults to `'button'` so the primitive never accidentally submits a form. Pass `'submit'` explicitly for form buttons.

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

leadingIcon

Icon rendered before the label. Choice / Selection swap it for a check icon when selected. Pivot / Input accept it as-is. CounterChip has no leading slot.

children

Chip label (or count for CounterChip). Wrapped in a <span data-oshon-slot='label'> for interactive variants.

trailingIcon

Pivot only. Optional icon rendered after the label (for e.g. a dropdown affordance on a pivot that opens a subfilter).

dismiss

Input only. Rendered when `onDismiss` is provided. Composes @oshon-ai/primitives/button so the dismiss carries its own permission + audit plumbing independent of any surrounding context.

Keyboard

Enter / Space: activate (Choice / Selection / Pivot). Tab: focus. Shift+Tab: focus backward. Choice: role="radio", aria-checked reflects `selected`. Selection: role="checkbox", aria-checked reflects `checked`. Pivot: aria-pressed reflects `pressed`. Input: non-button root; the dismiss button is keyboard-reachable via Tab and activates with Enter / Space. Counter: non-interactive — receives no focus.

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

Single-select (Choice)
<div role="radiogroup" aria-label="Plan">
  <Chip.Choice selected={plan === 'free'} onClick={() => setPlan('free')}>Free</Chip.Choice>
  <Chip.Choice selected={plan === 'pro'} onClick={() => setPlan('pro')}>Pro</Chip.Choice>
</div>
Multi-select (Selection)
<Chip.Selection checked={tags.has('bug')} onClick={() => toggle('bug')}>Bug</Chip.Selection>
Pivot toggle
<Chip.Pivot pressed={active === 'open'} onClick={() => setActive('open')}>Open</Chip.Pivot>
Removable tag (Input)
<Chip.Input onDismiss={() => remove(tag)} dismissLabel={`Remove ${tag}`}>{tag}</Chip.Input>

✗ Don't

Nesting a button inside a chip button
<Chip.Pivot>
  <button onClick={dismiss}>×</button>
</Chip.Pivot>

Nested interactive elements are invalid HTML and fail axe (WCAG 2.2 4.1.2). Use Chip.Input — its root is a <div> so the nested dismiss can itself be a real button.

Hardcoded color override
<Chip.Choice className="bg-blue-600">Free</Chip.Choice>

Breaks white-labeling. All colors must flow through @oshon-ai/tokens. If you need a different surface, add a variant to the manifest and compose it via tokens. See OSHON design principle #6.

CounterChip with an onClick
<Chip.Counter onClick={openDetails}>12</Chip.Counter>

CounterChip is a non-interactive leaf. If you need a clickable count, wrap it in a Button or compose it inside a Chip.Pivot — keep the count leaf-only so RSC benefits stick.

Choice without a radiogroup wrapper
<Chip.Choice selected={a} onClick={pickA}>A</Chip.Choice>
<Chip.Choice selected={b} onClick={pickB}>B</Chip.Choice>

Choice chips emit role="radio" — a screen reader expects a role="radiogroup" ancestor to announce group context ("1 of 3"). Wrap sibling Chip.Choice elements in <div role="radiogroup" aria-label="…">.

Design rationale

Chip fan-out follows ADR-002 (file-per-variant) for pin-point tree shaking; `chip-shared.tsx` holds the single source of truth for size scale, surface palettes, and icons so each variant file differs only in role semantics + slot contract. One family manifest (not five) matches the consumer mental model ("it's all Chip") and avoids 90%-duplicated manifest documents. The Choice/Selection visual similarity is intentional — the distinction is semantic, announced by assistive tech; visual convergence plus semantic divergence is a long-standing DS 3.1 pattern. CounterChip is the first leaf in @oshon-ai/components that doesn't need a client boundary; proving principle #5 here unlocks Badge / Avatar / Divider / Typography landing as RSC leaves later.