Quorum
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.
motionfree
"use client";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/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 active label 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]
);
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;
// Roving tabindex: exactly one focusable stop. The selected option owns
// it; if the selected one is disabled, the first enabled option does.
const tabbable = selected
? !option.disabled
: !activeValue &&
index === options.findIndex((o) => !o.disabled);
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}
disabled={option.disabled}
onClick={() => !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],
"hover:text-white/80",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-white/50",
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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/quorumManual — install dependencies, then copy the source
npm install motionProps
| 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 active label 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 all props of Omit< React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "role" > — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.