# Quorum — Animated Input for React

> A segmented control: 2–5 options with a single shared-layout pill that springs behind the active choice while its label ignites to full contrast and a faint accent bloom pulses on settle. Real radio semantics — role=radiogroup/radio, roving tabindex, Arrow/Home/End keyboard, focus-visible. Controlled or uncontrolled. The pill snaps under reduced motion.

- Page: https://crucibleui.com/components/inputs/quorum
- Category: [inputs](https://crucibleui.com/components/inputs)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/quorum
```

Registry JSON: https://crucibleui.com/r/quorum.json

Import from `@/components/crucible/quorum`.

## Props (`QuorumProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `QuorumOption[]` | — | The options, in order. 2–5 reads best. |
| `value?` | `string` | — | Controlled selected value. Omit for uncontrolled use (see `defaultValue`). |
| `defaultValue?` | `string` | `first enabled option` | Initial selected value when uncontrolled. |
| `onChange?` | `(value: string) => void` | — | Fires whenever the selection changes, controlled or not. |
| `aria-label?` | `string` | `"Choose an option"` | Accessible name for the group. Provide this OR an external label via `aria-labelledby`. |
| `accent?` | `string` | `sky` | The single cool accent — tints the focus ring and the settle bloom. |
| `disabled?` | `boolean` | `false` | Disable the whole control (all options unselectable). Individual options can be disabled via `QuorumOption.disabled`. |
| `paused?` | `boolean` | `false` | Freeze the spring — the pill still moves, but snaps. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Visual density. |
| `className?` | `string` | — | — |

Also accepts: `Omit< React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "role" >`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Quorum } from "@/components/crucible/quorum";

const OPTIONS = [
  { value: "grid", label: "Grid" },
  { value: "list", label: "List" },
  { value: "board", label: "Board" },
];

const LABEL = "text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600";

export default function QuorumDemo() {
  const [view, setView] = React.useState("list");

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full max-w-sm flex-col items-center gap-8 px-6">
        <div className="flex flex-col items-center gap-3">
          <span id="quorum-view-label" className={LABEL}>
            Pick a view
          </span>
          <Quorum
            options={OPTIONS}
            value={view}
            onChange={setView}
            aria-labelledby="quorum-view-label"
          />
        </div>

        <p className="text-sm text-neutral-500">
          Click an option, or arrow between them — the pill springs across.
        </p>

        <span className="h-5 text-xs text-neutral-600">
          Showing the <span className="text-neutral-300">{view}</span> view
        </span>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`quorum.tsx`)

```tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

export interface QuorumOption {
  /** Unique, stable value identifying this option. */
  value: string;
  /** Visible label. */
  label: React.ReactNode;
  /** Disable this option — skipped by pointer and keyboard navigation. */
  disabled?: boolean;
}

export interface QuorumProps
  extends Omit<
    React.ComponentPropsWithoutRef<"div">,
    "onChange" | "defaultValue" | "role"
  > {
  /** The options, in order. 2–5 reads best. */
  options: QuorumOption[];
  /** Controlled selected value. Omit for uncontrolled use (see `defaultValue`). */
  value?: string;
  /** Initial selected value when uncontrolled. @default first enabled option */
  defaultValue?: string;
  /** Fires whenever the selection changes, controlled or not. */
  onChange?: (value: string) => void;
  /**
   * Accessible name for the group. Provide this OR an external label via
   * `aria-labelledby`. @default "Choose an option"
   */
  "aria-label"?: string;
  /**
   * The single cool accent — tints the focus ring and the settle bloom.
   * @default sky
   */
  accent?: string;
  /**
   * Disable the whole control (all options unselectable). Individual options
   * can be disabled via `QuorumOption.disabled`. @default false
   */
  disabled?: boolean;
  /** Freeze the spring — the pill still moves, but snaps. @default false */
  paused?: boolean;
  /** Visual density. @default "md" */
  size?: "sm" | "md" | "lg";
  className?: string;
}

/** One cool accent on an otherwise neutral-elegant control. */
const DEFAULT_ACCENT = "#38bdf8";

const SIZES = {
  sm: "gap-0.5 p-0.5 text-[13px]",
  md: "gap-1 p-1 text-sm",
  lg: "gap-1 p-1.5 text-[15px]",
} as const;

const OPTION_SIZES = {
  sm: "px-3 py-1",
  md: "px-4 py-1.5",
  lg: "px-5 py-2",
} as const;

/**
 * Quorum — a segmented control. A row of 2–5 options with a single shared-layout
 * pill that springs behind the active option (via `layoutId`); the chosen label
 * ignites from muted neutral to full contrast and a faint accent bloom pulses as
 * the pill settles. Real radio semantics: `role="radiogroup"` wrapping
 * `role="radio"` buttons with roving tabindex, Arrow/Home/End keyboard, and
 * `focus-visible` rings. Controlled or uncontrolled. Under reduced motion the
 * pill snaps instantly (no spring) and the settle bloom is suppressed — the
 * resting selected state is fully styled, never blank.
 */
export function Quorum({
  options,
  value,
  defaultValue,
  onChange,
  "aria-label": ariaLabel = "Choose an option",
  "aria-labelledby": ariaLabelledby,
  accent = DEFAULT_ACCENT,
  disabled = false,
  paused = false,
  size = "md",
  className,
  ...props
}: QuorumProps) {
  const reducedMotion = useReducedMotion();
  const baseId = React.useId();
  const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
  // Bumped each time the pill finishes settling into an option; retriggers the
  // accent bloom pulse. Stays 0 on first paint so the control mounts at rest
  // with no flash.
  const [landCount, setLandCount] = React.useState(0);

  const firstEnabled =
    options.find((option) => !option.disabled)?.value ?? options[0]?.value;
  const [internalValue, setInternalValue] = React.useState(
    defaultValue ?? firstEnabled
  );
  const activeValue = value ?? internalValue;

  const select = React.useCallback(
    (next: string) => {
      if (value === undefined) setInternalValue(next);
      onChange?.(next);
    },
    [value, onChange]
  );

  const nextEnabledIndex = React.useCallback(
    (from: number, direction: 1 | -1) => {
      const count = options.length;
      let index = from;
      for (let step = 0; step < count; step++) {
        index = (index + direction + count) % count;
        if (!options[index]?.disabled) return index;
      }
      return from;
    },
    [options]
  );

  const focusAndSelect = React.useCallback(
    (index: number) => {
      const option = options[index];
      if (!option || option.disabled) return;
      select(option.value);
      optionRefs.current[index]?.focus();
    },
    [options, select]
  );

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      if (disabled) return;
      const currentIndex = options.findIndex((o) => o.value === activeValue);

      let nextIndex: number | null = null;
      if (event.key === "ArrowRight" || event.key === "ArrowDown")
        nextIndex = nextEnabledIndex(currentIndex, 1);
      else if (event.key === "ArrowLeft" || event.key === "ArrowUp")
        nextIndex = nextEnabledIndex(currentIndex, -1);
      else if (event.key === "Home") nextIndex = nextEnabledIndex(-1, 1);
      else if (event.key === "End") nextIndex = nextEnabledIndex(0, -1);

      if (nextIndex === null) return;
      event.preventDefault();
      focusAndSelect(nextIndex);
    },
    [disabled, options, activeValue, nextEnabledIndex, focusAndSelect]
  );

  // Roving tabindex: exactly one focusable stop. The selected option owns it;
  // if it is disabled or no option matches the value, the first enabled does.
  const selectedIndex = options.findIndex(
    (o) => o.value === activeValue && !o.disabled
  );
  const tabStop =
    selectedIndex !== -1 ? selectedIndex : options.findIndex((o) => !o.disabled);

  return (
    <div
      role="radiogroup"
      aria-label={ariaLabelledby ? undefined : ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-disabled={disabled || undefined}
      data-crucible="quorum"
      onKeyDown={handleKeyDown}
      className={cn(
        "relative inline-flex items-center rounded-full border border-white/10",
        SIZES[size],
        disabled && "pointer-events-none opacity-50",
        className
      )}
      style={{
        // Machined groove: a shaded well the pill sits down into.
        backgroundImage: "linear-gradient(180deg, #171719 0%, #0f0f11 100%)",
        boxShadow:
          "inset 0 1px 2px rgba(0,0,0,0.6), inset 0 -1px 0 rgba(255,255,255,0.03)",
      }}
      {...props}
    >
      {options.map((option, index) => {
        const selected = option.value === activeValue;
        const tabbable = index === tabStop;
        return (
          <button
            key={option.value}
            ref={(el) => {
              optionRefs.current[index] = el;
            }}
            role="radio"
            type="button"
            id={`${baseId}-opt-${option.value}`}
            aria-checked={selected}
            aria-disabled={option.disabled || undefined}
            tabIndex={tabbable ? 0 : -1}
            // Whole-control disable must also block Tab + Space/Enter, not just
            // pointer events, so the native disabled attribute covers both.
            disabled={disabled || option.disabled}
            onClick={() => !disabled && !option.disabled && select(option.value)}
            className={cn(
              "relative z-10 rounded-full font-medium whitespace-nowrap",
              "text-white/50 transition-colors duration-200",
              OPTION_SIZES[size],
              "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
              // Per-option dimming only — the whole-control disable already
              // fades the group, so don't double-dim every option.
              option.disabled ? "cursor-not-allowed opacity-40" : "hover:text-white/80",
              selected && "text-white"
            )}
            style={
              { ["--tw-ring-color" as string]: accent } as React.CSSProperties
            }
          >
            {selected && (
              <motion.span
                layoutId={`${baseId}-quorum-pill`}
                aria-hidden
                className="absolute inset-0 -z-10 rounded-full"
                style={{
                  // Machined pill: lit from above, a bright top hairline, a soft
                  // drop shadow so it rides ABOVE the groove, and a hairline rim.
                  backgroundImage:
                    "linear-gradient(180deg, #3b3b3f 0%, #2a2a2d 55%, #232326 100%)",
                  boxShadow: [
                    "inset 0 1px 0 rgba(255,255,255,0.22)",
                    "inset 0 -1px 0 rgba(0,0,0,0.45)",
                    "0 1px 1px rgba(0,0,0,0.5)",
                    "0 2px 6px -1px rgba(0,0,0,0.55)",
                    "0 0 0 1px rgba(255,255,255,0.08)",
                  ].join(", "),
                }}
                transition={
                  reducedMotion || paused
                    ? { duration: 0 }
                    : { type: "spring", stiffness: 480, damping: 30, mass: 0.9 }
                }
                onLayoutAnimationComplete={() => {
                  if (!reducedMotion && !paused) setLandCount((c) => c + 1);
                }}
              >
                {/* Settle bloom: a faint accent flush fired the instant the pill
                    lands. Gated on landCount>0 so mount is calm; suppressed under
                    reduced motion. */}
                <AnimatePresence>
                  {landCount > 0 && (
                    <motion.span
                      key={landCount}
                      className="pointer-events-none absolute inset-0 rounded-full"
                      style={{
                        backgroundColor: accent,
                        boxShadow: `0 0 20px 1px ${accent}`,
                      }}
                      initial={{ opacity: 0.28 }}
                      animate={{ opacity: 0 }}
                      exit={{ opacity: 0 }}
                      transition={{ duration: 0.4, ease: "easeOut" }}
                    />
                  )}
                </AnimatePresence>
              </motion.span>
            )}
            <span className="relative">{option.label}</span>
          </button>
        );
      })}
    </div>
  );
}
```
