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

Toggle Group

Mutually-exclusive set of toggles.

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 togglegroup

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

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

Default

tsx
<ToggleGroup size="m" ariaLabel="View" defaultValue="week" items={[
          { value: 'day', label: 'Day' },
          { value: 'week', label: 'Week' },
          { value: 'month', label: 'Month' },
        ]} />

Size matrix

tsx
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
      <div key="xs" data-story-size="xs">
        <ToggleGroup size="xs" ariaLabel="View" defaultValue="week" items={[
              { value: 'day', label: 'Day' },
              { value: 'week', label: 'Week' },
              { value: 'month', label: 'Month' },
            ]} />
      </div>
      <div key="s" data-story-size="s">
        <ToggleGroup size="s" ariaLabel="View" defaultValue="week" items={[
              { value: 'day', label: 'Day' },
              { value: 'week', label: 'Week' },
              { value: 'month', label: 'Month' },
            ]} />
      </div>
      <div key="m" data-story-size="m">
        <ToggleGroup size="m" ariaLabel="View" defaultValue="week" items={[
              { value: 'day', label: 'Day' },
              { value: 'week', label: 'Week' },
              { value: 'month', label: 'Month' },
            ]} />
      </div>
      <div key="l" data-story-size="l">
        <ToggleGroup size="l" ariaLabel="View" defaultValue="week" items={[
              { value: 'day', label: 'Day' },
              { value: 'week', label: 'Week' },
              { value: 'month', label: 'Month' },
            ]} />
      </div>
      <div key="mobile" data-story-size="mobile">
        <ToggleGroup size="mobile" ariaLabel="View" defaultValue="week" items={[
              { value: 'day', label: 'Day' },
              { value: 'week', label: 'Week' },
              { value: 'month', label: 'Month' },
            ]} />
      </div>
    </div>

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

items

Required array of `ToggleGroupItem` — `{ value, label?, icon?, disabled?, ariaLabel? }`. Order is preserved; `value` doubles as the React key + the emitted change payload.

item.icon

Optional leading glyph per item — typically a 14 px SVG using `currentColor` so it tints in sync with the active/inactive state. Sized via the size-axis CSS custom property.

item.label

Visible text label. Optional only when `icon` + `ariaLabel` are both supplied (icon-only button). Mixing icon + label produces the standard `[icon] [text]` cluster.

Keyboard

In `type="single"` mode the wrapper is `role="radiogroup"` and each button is `role="radio"` with `aria-checked`. Per WAI-ARIA RadioGroup pattern: only the active radio sits in the tab order; ArrowLeft/Up + ArrowRight/Down rotate focus + selection (skipping disabled items + wrapping at the ends), Home jumps to the first focusable item, End jumps to the last. In `type="multiple"` mode the wrapper is `role="group"` and each button carries `aria-pressed`; every button is in the tab order, Space/Enter toggles. Disabled items receive `disabled` (native button disabled — not in tab order, not clickable). Icon-only items must supply `ariaLabel`; the component falls back to `value` if neither label nor ariaLabel is provided.

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-30

Do / Don't

✓ Do

View switcher (single, default)
<ToggleGroup
  ariaLabel="View"
  items={[
    { value: 'day',   label: 'Day'   },
    { value: 'week',  label: 'Week'  },
    { value: 'month', label: 'Month' },
  ]}
  defaultValue="week"
  onChange={(v) => setView(v)}
/>
Formatting toolbar (multiple, icon-only)
<ToggleGroup
  type="multiple"
  ariaLabel="Text style"
  items={[
    { value: 'b', icon: <BoldIcon />,      ariaLabel: 'Bold'      },
    { value: 'i', icon: <ItalicIcon />,    ariaLabel: 'Italic'    },
    { value: 'u', icon: <UnderlineIcon />, ariaLabel: 'Underline' },
  ]}
  defaultValue={['b']}
/>
Separated chips with min-1 floor (single, allowEmpty=false)
<ToggleGroup
  variant="separated"
  ariaLabel="Sort"
  items={[
    { value: 'asc',  label: '↑ Ascending'  },
    { value: 'desc', label: '↓ Descending' },
  ]}
  defaultValue="asc"
/>
Disabled item (skipped by arrow nav)
<ToggleGroup
  ariaLabel="Plan"
  items={[
    { value: 'free',       label: 'Free' },
    { value: 'pro',        label: 'Pro' },
    { value: 'enterprise', label: 'Enterprise', disabled: true },
  ]}
/>

✗ Don't

Mixing single + multiple inside one group
<ToggleGroup type="single" items={[/* mix of mutually-exclusive AND independent options */]} />

Pick one mode per group. Mutually-exclusive views (Day/Week/Month) are `single`; independent flags (Bold/Italic/Underline) are `multiple`. Mixing the two semantics in one row breaks the user's mental model and the keyboard contract — RadioGroup arrow-nav rotates selection, which is wrong for independent flags.

Forcing a width via `style.width`
<ToggleGroup style={{ width: 320 }} items={items} />

Use `fullWidth` when the group should span its container; otherwise let the buttons size themselves. Hardcoding a pixel width breaks the responsive flow and the size-axis math.

Putting > 7 items in one group
<ToggleGroup items={tenDifferentValues} />

Toggle groups are eyeable in one glance — past ~7 buttons the row wraps, the segmented chrome stops cohering visually, and it's harder to scan than a single `Select` / `Dropdown`. Reach for `Dropdown`, `Select`, or a `Menu` for higher-cardinality choices.

Using ToggleGroup as primary navigation
<ToggleGroup items={[{ value: 'home' }, { value: 'about' }, { value: 'contact' }]} />

A toggle group reflects in-page state, not navigation between pages. For top-level routes use `Tabs` (which also produces the segmented look but ships proper `role="tablist"` semantics + history integration).

Design rationale

A toggle group sits at the intersection of three established WAI-ARIA patterns — RadioGroup (single + arrow nav), pressed-button toolbar (multiple), and segmented control (visual). Rather than expose a compound API (`<ToggleGroup><ToggleGroup.Item />`) we follow the Breadcrumbs / WidgetTrendList convention of a single prop-driven items array — easier to type, easier to memoize, no provider plumbing. The two visual variants (`segmented` / `separated`) cover the bulk of the design landscape while a single five-size axis keeps the typography density consistent with the rest of Oshon. Uncontrolled-first matches `Toggle`, `Checkbox`, and every other Phase 4 stateful control: pass `value` to drive externally, ignore it to let the component own the state.