Segmented Picker
A picker where the pill flows: one edge leaves before the other.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { SegmentedPicker } from "./SegmentedPicker";
const options = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
{ value: "year", label: "Year" },
];
export default function Demo() {
const [value, setValue] = useState("week");
const [turn, setTurn] = useState(0);
useAutoplay(() => {
setValue(options[[0, 3, 1, 2][turn % 4]].value);
setTurn((t) => t + 1);
}, 1000);
return <SegmentedPicker label="Period" value={value} onChange={setValue} options={options} />;
}Installation
npx @usefluid/cli add segmented-pickerpnpm dlx @usefluid/cli add segmented-pickeryarn dlx @usefluid/cli add segmented-pickerbunx @usefluid/cli add segmented-pickerInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/segmented-picker/SegmentedPicker.tsx "use client"; import { useSpring } from "@usefluid/motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import styles from "./SegmentedPicker.module.css"; const LEAD = { w: 34, z: 0.8 }; const TRAIL = { w: 17, z: 0.78 }; type Option<T> = { value: T; label: ReactNode; ariaLabel?: string }; type Props<T> = { label: string; value: T; onChange(value: NoInfer<T>): void; options: Option<NoInfer<T>>[] }; export function SegmentedPicker<T extends string>({ label, value, onChange, options }: Props<T>) { const root = useRef<HTMLDivElement>(null); const pill = useRef<HTMLSpanElement>(null); const [box, setBox] = useState({ left: 0, right: 0, ready: false }); const placed = useRef({ ready: false, left: 0 }); useLayoutEffect(() => { const el = root.current; if (!el) return; const measure = () => { const active = el.querySelector<HTMLElement>('[aria-checked="true"]'); if (active) setBox({ left: active.offsetLeft, right: active.offsetLeft + active.offsetWidth, ready: true }); }; measure(); const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); }, [value]); useEffect(() => { placed.current = { ready: box.ready, left: box.left }; }, [box]); const movingRight = box.left >= placed.current.left; const immediate = !placed.current.ready; const edges = useRef({ left: 0, right: 0 }); const paint = () => { if (!pill.current) return; pill.current.style.left = `${edges.current.left}px`; pill.current.style.width = `${Math.max(0, edges.current.right - edges.current.left)}px`; }; useSpring( { v: box.left }, ({ v }) => { edges.current.left = v; paint(); }, { ...(movingRight ? TRAIL : LEAD), immediate }, ); useSpring( { v: box.right }, ({ v }) => { edges.current.right = v; paint(); }, { ...(movingRight ? LEAD : TRAIL), immediate }, ); const move = (e: KeyboardEvent, index: number) => { const delta = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0; if (!delta) return; e.preventDefault(); const next = (index + delta + options.length) % options.length; onChange(options[next].value); root.current?.querySelectorAll<HTMLElement>('[role="radio"]')[next]?.focus(); }; return ( <div ref={root} role="radiogroup" aria-label={label} className={styles.root}> <span ref={pill} className={styles.pill} data-ready={box.ready} aria-hidden /> {options.map((option, i) => ( <button key={option.value} type="button" role="radio" aria-checked={option.value === value} aria-label={option.ariaLabel} tabIndex={option.value === value ? 0 : -1} className={styles.option} onClick={() => onChange(option.value)} onKeyDown={(e) => move(e, i)} > {option.label} </button> ))} </div> ); }components/fluid/segmented-picker/SegmentedPicker.module.css .root { position: relative; display: inline-flex; box-sizing: border-box; max-width: 100%; overflow-x: auto; scrollbar-width: none; padding: 4px; border-radius: calc(var(--fluid-radius) + 4px); background: color-mix(in srgb, var(--fluid-text) 8%, transparent); isolation: isolate; } .pill { position: absolute; z-index: -1; top: 4px; bottom: 4px; border-radius: var(--fluid-radius); background: var(--fluid-accent); } .pill[data-ready="false"] { visibility: hidden; } .option { min-height: 44px; min-width: 44px; padding: 0 14px; border: 0; border-radius: var(--fluid-radius); background: none; color: var(--fluid-text); font: 500 14px/1 var(--fluid-font); white-space: nowrap; cursor: pointer; touch-action: manipulation; transition: color 160ms ease; } .option[aria-checked="true"] { color: var(--fluid-on-accent); } .option:focus-visible { outline: 2px solid var(--fluid-focus); outline-offset: 2px; } @media (max-width: 480px) { .root { display: flex; width: 100%; } .option { flex: 1 0 auto; padding: 0 10px; font-size: 13px; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { SegmentedPicker } from "./SegmentedPicker";
<SegmentedPicker
label="Show"
value={filter}
onChange={setFilter}
options={[
{ value: "all", label: "All" },
{ value: "active", label: "Active" },
{ value: "done", label: "Done" },
]}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Accessible name of the group. |
value | string | — | The chosen option. |
onChange | (value) => void | — | |
options | { value, label, ariaLabel? }[] | — | label can be any node, like a word and a count. |
Keyboard and screen readers
A radio group: arrow keys move between options.
Notes
Use it for filters and views of the same list. Under 480 px it fills the width and the options share it.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
Each edge of the pill has its own spring. The leading edge is fast and the trailing one is slow, so the pill stretches on the way and shrinks when it arrives, like a drop. The arrow keys change the option.
For your agent
For your agent
Add Segmented Picker to my project with the Fluid library. Use SegmentedPicker for filters and tabs with up to five short options. Read my project first. Use my logo, my colours and my font through the --fluid-* variables. Keep 44px touch targets, visible focus and reduced motion. If I have no SVG logo, ask before making one up.