Multi Select Chips
Picked options become chips in the row above the list, and the chips already there slide over to take each new one in.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Multi Select Chips
*
* Options picked from the list below turn into chips in the row above it.
* A new chip rises into the row and the chips already there slide over to
* take it in; removing one closes the gap the same way.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are mixed
* from the inherited text color, so the control reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `options`, `defaultSelected`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type MultiSelectChipsProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Choices offered in the list. */
options?: readonly string[];
/** Choices already picked on first render. */
defaultSelected?: readonly string[];
/** Heading above the chip row. */
label?: string;
/** Accent for chips, ticks and focus. */
accent?: string;
/** Overall width. */
width?: number | string;
/** Fires with the full selection whenever it changes. */
onSelectionChange?: (selected: string[]) => void;
};
type VariantConfig = {
/** Spring that carries a chip into the row and the others over. */
settle: { type: "spring"; stiffness: number; damping: number };
/** How far a chip rises from the list, in px. */
rise: number;
/** Seconds for a chip to fade in or out. */
fade: number;
};
// Quality rule: chips carry text, so they travel with `layout="position"`
// — position only, never a size interpolation that would stretch the
// glyphs inside. Every spring sits at or above a 0.8 damping ratio, so a
// chip arrives with one soft settle and the row never jitters while the
// user works down the list.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Crisp, no settle worth noticing. For a filter bar that fills up fast.
subtle: {
settle: { type: "spring", stiffness: 720, damping: 52 },
rise: 2,
fade: 0.09,
},
// One soft settle as the chip lands. The all-purpose setting.
default: {
settle: { type: "spring", stiffness: 520, damping: 40 },
rise: 8,
fade: 0.16,
},
// More travel from the list into the row, same single settle.
playful: {
settle: { type: "spring", stiffness: 350, damping: 31 },
rise: 14,
fade: 0.23,
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_OPTIONS = [
"Design",
"Engineering",
"Marketing",
"Support",
"Finance",
] as const;
export default function MultiSelectChips({
variant = "default",
options = DEFAULT_OPTIONS,
defaultSelected = ["Engineering"],
label = "Notify these teams",
accent = "#5B5BD6",
width = 320,
onSelectionChange,
}: MultiSelectChipsProps) {
const [selected, setSelected] = useState<string[]>([...defaultSelected]);
const [focused, setFocused] = useState<string | null>(null);
const groupId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const toggle = (option: string) => {
// Selection keeps the list's own order, so a chip never jumps to the
// end of the row on a re-pick — the row reads the same way twice.
const next = selected.includes(option)
? selected.filter((entry) => entry !== option)
: options.filter((entry) => entry === option || selected.includes(entry));
setSelected(next);
onSelectionChange?.(next);
};
const settle = reduceMotion ? { duration: 0 } : cfg.settle;
return (
<div style={{ width, color: "inherit" }}>
<div
id={`${groupId}-label`}
style={{
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.3,
opacity: 0.55,
marginBottom: 8,
}}
>
{label.toUpperCase()}
</div>
{/* The chip row. It keeps its height when empty so the list below
never shifts as the first chip arrives. */}
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 6,
minHeight: 34,
padding: "6px 8px",
boxSizing: "border-box",
borderRadius: 11,
border: `1px dashed ${tone(14)}`,
background: tone(4),
}}
>
<AnimatePresence initial={false} mode="popLayout">
{selected.length === 0 ? (
<motion.span
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 0.45 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : cfg.fade }}
style={{ fontSize: 12, padding: "2px 2px" }}
>
Nothing selected yet
</motion.span>
) : (
selected.map((option) => (
<motion.span
key={option}
// Position-only layout: the chip slides to its new spot in
// the row without its box being interpolated, which is
// what would otherwise stretch the label inside it.
layout="position"
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: cfg.rise }
}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={
reduceMotion
? { duration: 0.1 }
: {
layout: settle,
y: cfg.settle,
opacity: { duration: cfg.fade, ease: "easeOut" },
}
}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 5px 4px 10px",
borderRadius: 999,
border: `1px solid color-mix(in srgb, ${accent} 40%, transparent)`,
background: `color-mix(in srgb, ${accent} 16%, transparent)`,
fontSize: 12,
fontWeight: 550,
whiteSpace: "nowrap",
}}
>
{option}
<button
type="button"
aria-label={`Remove ${option}`}
onClick={() => toggle(option)}
onFocus={() => setFocused(`chip-${option}`)}
onBlur={() => setFocused(null)}
style={{
display: "grid",
placeItems: "center",
width: 17,
height: 17,
padding: 0,
borderRadius: "50%",
border: 0,
background:
focused === `chip-${option}` ? tone(20) : tone(10),
color: "inherit",
cursor: "pointer",
outline: "none",
boxShadow:
focused === `chip-${option}`
? `0 0 0 2px color-mix(in srgb, ${accent} 50%, transparent)`
: "0 0 0 0 transparent",
transition:
"background-color 140ms ease-out, box-shadow 140ms ease-out",
}}
>
<svg
width="9"
height="9"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2.6"
strokeLinecap="round"
aria-hidden
>
<path d="M5 5l10 10M15 5 5 15" />
</svg>
</button>
</motion.span>
))
)}
</AnimatePresence>
</div>
{/* The list. Real checkboxes: space toggles, tab moves, and the
selection is announced without depending on the chips. */}
<fieldset
style={{
margin: "10px 0 0",
padding: 0,
border: 0,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<legend
style={{
padding: 0,
fontSize: 11,
opacity: 0.45,
marginBottom: 4,
}}
>
Available teams
</legend>
{options.map((option) => {
const isSelected = selected.includes(option);
const showFocus = focused === `option-${option}`;
return (
<label
key={option}
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px",
borderRadius: 10,
background: isSelected ? tone(7) : "transparent",
boxShadow: showFocus
? `0 0 0 2px color-mix(in srgb, ${accent} 45%, transparent)`
: "0 0 0 0 transparent",
cursor: "pointer",
transition:
"background-color 150ms ease-out, box-shadow 140ms ease-out",
}}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggle(option)}
onFocus={() => setFocused(`option-${option}`)}
onBlur={() => setFocused(null)}
style={{
position: "absolute",
width: 1,
height: 1,
margin: 0,
padding: 0,
opacity: 0,
pointerEvents: "none",
}}
/>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 16,
height: 16,
flex: "0 0 auto",
borderRadius: 5,
border: `1.5px solid ${isSelected ? accent : tone(26)}`,
background: isSelected ? accent : "transparent",
color: "#FFFFFF",
transition:
"background-color 150ms ease-out, border-color 150ms ease-out",
}}
>
<motion.svg
initial={false}
animate={{ opacity: isSelected ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : cfg.fade }}
width="10"
height="10"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m4.5 10.5 3.8 3.8L15.5 6" />
</motion.svg>
</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{option}</span>
</label>
);
})}
</fieldset>
<div
role="status"
aria-live="polite"
style={{ marginTop: 8, fontSize: 11.5, opacity: 0.5 }}
>
{selected.length === 0
? "No teams selected"
: `${selected.length} of ${options.length} teams selected`}
</div>
</div>
);
}About this pattern
A multi-select is only usable when the answer so far is visible without scrolling back, so every pick leaves the list and appears as a chip in the row above it. The new chip rises the short distance it travelled and the chips beside it slide over to make the space, using position-only layout — the box is never interpolated, so the label inside a chip is never stretched. Removing one closes the gap by the same motion, and the row keeps its height when empty so the list below never shifts under the pointer as the first chip lands.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
Chosen labels gather as chips while the option list stays open.