Skip to content

SlidingPill

Hero example

When to use

Reach for SlidingPill when you need a horizontal switcher between two to five related views and the visual identity is "tabs with a sliding indicator" — the canonical mounts are the lifecycle-stage switcher on entity pages (Draft / Active / Dissolving), the timescale switcher in the Compliance view (Week / Month / Quarter), and the agent-tier switcher in the developer playground (tier_1 / tier_2 / tier_3 / tier_4). The animated indicator is the visual identity; if you don't need the animation, use a plain button group.

Don't reach for SlidingPill for more than ~5 items — the indicator track grows past the readable width and the slide animation reads as restless. For 6+ tabs use a <Tabs> primitive. Don't use it for navigation between routes either — the indicator implies a single-surface switch, not a page change. If you need route navigation, use a navigation pill with <Link> items.

Do — render SlidingPill for in-place view switches (timescale, status filter, density toggle on a single chart).
Don't — render SlidingPill for top-level navigation. The indicator implies same-page switching.

Anatomy

Three structural parts:

  1. Track. Outer <div class="m-sliding-pill">. Carries the size modifier class (--sm / --md / --lg).
  2. Indicator. Absolute-positioned <span aria-hidden> that reads its transform: translateX(…) and width from the active item's measured rect. Hidden (opacity 0) when no item is active.
  3. Items. One <button class="m-sliding-pill__item"> per SlidingPillItem. Each carries data-id="{id}" and data-active="true|false". The active item's foreground is brighter; inactive items are muted.

The slide math: on every active change, useLayoutEffect measures the active button's offsetLeft and offsetWidth, then sets the indicator's translation and width. The CSS recipe handles the transition.

Props

PropTypeDefaultDescription
itemsrequiredSlidingPillItem[]
activerequiredstring
onChangerequired(id: string) => void
size"sm" | "md" | "lg""md"
classNamestring

size defaults to "md". Use "sm" for dense surfaces (Composer chip row), "lg" for hero-tier switchers (entity-page header).

States

StateTriggerVisual
Default (no active)active doesn't match any item idIndicator hidden (opacity 0); all items muted
Active item setactive matches an item idIndicator slides to that item; item foreground brightens
Hover (inactive item)Mouse hoverItem foreground brightens slightly
Focus-visibleKeyboard focusPeach ring on the focused item
Emptyitems: []Empty track renders without crashing — defensive against data-loading races

Density

The size prop controls visual density; the site-level density toggle has no effect on SlidingPill.

sizeHeightUse
sm24pxComposer chip row, dense filter bars
md (default)32pxEntity-page lifecycle switcher, in-card view-mode pills
lg40pxMarketing hero, full-page tab switchers

Themes

In dark, the track background drops to a semi-transparent ink fill; the indicator uses the canonical glass surface. In forced-colors, the indicator collapses to Highlight and active-item foreground to HighlightText.

Tokens consumed

No tokens match.
radii2 tokensv2.3.0
999
matter
src ↗
9999
brand
src ↗

Track background reads --ink-4 / --ink-6 on hover. Indicator background reads --surface-glass (or --surface-card if glass isn't available). Active foreground reads --fg; inactive reads --fg-muted. Border radius reads --radius-pill.

Accessibility

  • Keyboard interactions. Each item is a real <button>. Tab moves between items; Enter and Space activate. Arrow keys do not move focus — Tab is the contract.
  • ARIA roles and properties. Default to implicit button role. If you need radio-group semantics, wrap the SlidingPill in role="tablist" and pass role="tab" plus aria-selected via custom item rendering at the consumer layer (the current API doesn't propagate role).
  • Focus order. DOM order. The active item is not skipped — Tab still lands on it.
  • Screen-reader expectations. Each item announces as "{label}, button." The active state must be conveyed to assistive tech by the consumer (pass aria-pressed or aria-selected if used as a toggle group).
  • Reduced motion. Indicator slide transition collapses to instant under prefers-reduced-motion: reduce. The indicator still moves to the correct rect; it just doesn't animate the path.
  • Forced colors. Indicator → Highlight; active foreground → HighlightText; track → Canvas with ButtonBorder.
  • WIG rules. Real <button> elements (semantic-element rule). touch-action: manipulation on items prevents 300ms double-tap delay. :focus-visible ring (not :focus) per the focus rule. Indicator is aria-hidden (decorative).

Do / Don't

Do — keep items between 2 and 5. The track is tuned to that range.
Don't — render SlidingPill with one item. The indicator implies multi-choice; one item is just a button.
Do — store the active id in URL state (?view=week) so the choice deep-links and survives refresh (WIG navigation rule).
Don't — store the active id only in component state. View-mode preferences are bookmarkable contexts.
Do — derive item labels from a single source of truth (e.g. an enum's display_name). Don't duplicate the canonical labels.
Don't — hardcode item labels inline if the enum lives elsewhere. Labels drift over time.

Recipes

This component appears in:

  • Equity recipe — the timescale switcher above the cap-table donut.
  • Docs corpus recipe — the view-mode switcher (Tree / List / Graph) on the corpus explorer.

Code example

import { SlidingPill } from "@matter/components";
import { useState } from "react";

export function TimescaleSwitcher() {
  const [view, setView] = useState("month");
  return (
    <SlidingPill
      items={[
        { id: "week", label: "Week" },
        { id: "month", label: "Month" },
        { id: "quarter", label: "Quarter" },
        { id: "year", label: "Year" },
      ]}
      active={view}
      onChange={setView}
      size="md"
    />
  );
}

Source

packages/components/src/SlidingPill

On this page