Roster
A tag / multi-select input over a real <input>. Type and press Enter or comma to pop in a removable chip; an optional suggestion pool filters as you type into a keyboard-navigable listbox, with a duplicate guard and a live-region announcing every add and remove. Backspace on an empty field peels the last chip; chips are focusable and removable with Delete. Neutral with one cool accent; reduced motion adds and removes instantly.
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 RosterProps {
/** Controlled list of tags. Omit for uncontrolled use (see `defaultValue`). */
value?: string[];
/** Initial tags when uncontrolled. @default [] */
defaultValue?: string[];
/** Fires whenever the tag set changes, with the tags in order. */
onChange?: (tags: string[]) => void;
/**
* Optional suggestion pool, filtered against the query as you type. Selecting
* one (click / Enter on the highlighted option) adds it. @default []
*/
suggestions?: string[];
/** Maximum number of tags. Adds are refused (and announced) at the cap. @default undefined — no limit */
max?: number;
/** Placeholder shown in the empty input. @default "Add tags…" */
placeholder?: string;
/**
* Case-insensitive duplicate guard when false — "React" and "react" collide.
* @default false
*/
caseSensitive?: boolean;
/** Accessible name for the field. @default "Tags" */
label?: string;
/** The single cool accent for focus ring, chips, and highlighted options. @default sky */
accent?: string;
/** Disable the whole control. @default false */
disabled?: boolean;
/** Freeze the chip pop / removal motion (static resting pose). @default false */
paused?: boolean;
className?: string;
}
const DEFAULT_ACCENT = "#38bdf8";
/** Normalise a tag for comparison under the case-sensitivity rule. */
function normalize(tag: string, caseSensitive: boolean): string {
const t = tag.trim();
return caseSensitive ? t : t.toLowerCase();
}
/**
* Roster — a tag / multi-select input backed by a real `<input>`. Type and press
* Enter (or comma) to commit a chip; each chip pops in with a soft spring and is
* removable by its × button, by focusing it and pressing Delete/Backspace, or by
* pressing Backspace in an empty field (which peels off the last chip). An
* optional suggestion pool filters as you type into a keyboard-navigable listbox
* (↑/↓ to move, Enter to pick, Esc to dismiss); duplicates are refused. Every
* add and remove is announced through a polite live region, chips are focusable,
* and the field carries full combobox semantics. One cool accent lifts focus and
* the chips; everything else is neutral. Reduced motion adds and removes chips
* instantly with no spring.
*/
export function Roster({
value,
defaultValue = [],
onChange,
suggestions = [],
max,
placeholder = "Add tags…",
caseSensitive = false,
label = "Tags",
accent = DEFAULT_ACCENT,
disabled = false,
paused = false,
className,
}: RosterProps) {
const reducedMotion = useReducedMotion();
const still = reducedMotion || paused;
const listboxId = React.useId();
const optionBaseId = React.useId();
const inputRef = React.useRef<HTMLInputElement>(null);
const chipRefs = React.useRef(new Map<string, HTMLButtonElement>());
const pendingFocus = React.useRef<string | "input" | null>(null);
const [internal, setInternal] = React.useState<string[]>(defaultValue);
const [query, setQuery] = React.useState("");
const [focused, setFocused] = React.useState(false);
const [open, setOpen] = React.useState(false);
const [active, setActive] = React.useState(-1); // highlighted suggestion index
const [announcement, setAnnouncement] = React.useState("");
const isControlled = value !== undefined;
const tags = isControlled ? value : internal;
const atCapacity = max != null && tags.length >= max;
const commit = React.useCallback(
(next: string[]) => {
if (!isControlled) setInternal(next);
onChange?.(next);
},
[isControlled, onChange]
);
// Suggestions matching the query and not already chosen. Empty query with a
// focused field surfaces the remaining pool so the list can be browsed.
const matches = React.useMemo(() => {
if (!suggestions.length) return [] as string[];
const q = normalize(query, caseSensitive);
const chosen = new Set(tags.map((t) => normalize(t, caseSensitive)));
return suggestions.filter((s) => {
const n = normalize(s, caseSensitive);
if (chosen.has(n)) return false;
if (!q) return true;
return caseSensitive ? s.includes(query.trim()) : n.includes(q);
});
}, [suggestions, query, tags, caseSensitive]);
const showList = open && !atCapacity && matches.length > 0;
const addTag = React.useCallback(
(raw: string): boolean => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (atCapacity) {
setAnnouncement(`Maximum of ${max} tags reached`);
return false;
}
const n = normalize(trimmed, caseSensitive);
if (tags.some((t) => normalize(t, caseSensitive) === n)) {
setAnnouncement(`${trimmed} is already added`);
return false;
}
commit([...tags, trimmed]);
setAnnouncement(`Added ${trimmed}`);
return true;
},
[atCapacity, caseSensitive, commit, max, tags]
);
const removeAt = React.useCallback(
(index: number, restoreFocus: "input" | "adjacent" = "adjacent") => {
const target = tags[index];
if (target === undefined) return;
if (restoreFocus === "adjacent") {
// Land focus on the next chip, else the previous, else the input.
pendingFocus.current = tags[index + 1] ?? tags[index - 1] ?? "input";
} else {
pendingFocus.current = "input";
}
chipRefs.current.delete(target);
commit(tags.filter((_, i) => i !== index));
setAnnouncement(`Removed ${target}`);
},
[commit, tags]
);
// After a removal, move focus to the adjacent chip (or back to the input).
React.useEffect(() => {
const target = pendingFocus.current;
if (!target) return;
pendingFocus.current = null;
if (target === "input") inputRef.current?.focus();
else chipRefs.current.get(target)?.focus();
}, [tags]);
// Reset the highlight whenever the visible option set changes.
React.useEffect(() => {
setActive((a) => (a >= matches.length ? matches.length - 1 : a));
}, [matches.length]);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
// Commit on Enter or comma.
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const picked = showList && active >= 0 ? matches[active] : query;
if (addTag(picked)) {
setQuery("");
setActive(-1);
setOpen(false);
}
return;
}
if (e.key === "Backspace" && query === "" && tags.length > 0) {
// Peel the last chip when the field is empty.
e.preventDefault();
removeAt(tags.length - 1, "input");
return;
}
if (!showList) {
if (e.key === "ArrowDown" && matches.length > 0 && !atCapacity) {
e.preventDefault();
setOpen(true);
setActive(0);
}
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => (a + 1) % matches.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a - 1 + matches.length) % matches.length);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
setActive(-1);
} else if (e.key === "Home") {
e.preventDefault();
setActive(0);
} else if (e.key === "End") {
e.preventDefault();
setActive(matches.length - 1);
}
};
const handleChipKeyDown = (
e: React.KeyboardEvent<HTMLButtonElement>,
index: number
) => {
if (disabled) return;
if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
removeAt(index, "adjacent");
} else if (e.key === "ArrowRight") {
e.preventDefault();
const next = tags[index + 1];
if (next) chipRefs.current.get(next)?.focus();
else inputRef.current?.focus();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
const prev = tags[index - 1];
if (prev) chipRefs.current.get(prev)?.focus();
}
};
const chipTransition = still
? { duration: 0 }
: ({ type: "spring", stiffness: 620, damping: 26 } as const);
return (
<div
data-crucible="roster"
className={cn("w-full max-w-md", disabled && "opacity-50", className)}
>
{/* Polite live region — every add / remove / refusal is announced. */}
<div aria-live="polite" role="status" className="sr-only">
{announcement}
</div>
<div
onClick={() => inputRef.current?.focus()}
className={cn(
"relative flex flex-wrap items-center gap-1.5 rounded-xl border px-2 py-2",
"border-neutral-700 bg-neutral-900/40 transition-colors duration-200",
!disabled && "cursor-text",
focused ? "border-transparent" : "hover:border-neutral-600"
)}
>
{/* Focus ring drawn manually so it adopts the accent hue. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 rounded-xl transition-opacity duration-200",
focused ? "opacity-100" : "opacity-0"
)}
style={{ boxShadow: `0 0 0 1.5px ${accent}, 0 0 18px -6px ${accent}` }}
/>
<ul className="contents" aria-label={`${label}, ${tags.length} selected`}>
<AnimatePresence initial={false} mode="popLayout">
{tags.map((tag, index) => (
<motion.li
key={tag}
layout={still ? false : true}
initial={still ? false : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={still ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
transition={chipTransition}
className="relative z-10 flex"
>
<button
type="button"
ref={(el) => {
if (el) chipRefs.current.set(tag, el);
else chipRefs.current.delete(tag);
}}
disabled={disabled}
aria-label={`Remove ${tag}`}
onKeyDown={(e) => handleChipKeyDown(e, index)}
onClick={() => removeAt(index, "adjacent")}
className={cn(
"group inline-flex items-center gap-1 rounded-lg py-1 pl-2.5 pr-1.5 text-sm",
"border border-neutral-700 bg-neutral-800/70 text-neutral-100",
"outline-none transition-colors duration-150",
"hover:border-neutral-600 focus-visible:border-transparent"
)}
style={{ ["--tw-ring-color" as string]: accent }}
>
{/* Accent focus outline for the chip. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-lg opacity-0 transition-opacity group-focus-visible:opacity-100"
style={{ boxShadow: `0 0 0 1.5px ${accent}` }}
/>
<span className="truncate">{tag}</span>
<span
aria-hidden
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded",
"text-neutral-400 transition-colors",
"group-hover:text-neutral-100 group-focus-visible:text-neutral-100"
)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</span>
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
<input
ref={inputRef}
type="text"
role="combobox"
aria-label={label}
aria-expanded={showList}
aria-controls={showList ? listboxId : undefined}
aria-autocomplete="list"
aria-activedescendant={
showList && active >= 0 ? `${optionBaseId}-${active}` : undefined
}
aria-disabled={atCapacity || undefined}
value={query}
disabled={disabled}
placeholder={tags.length === 0 || focused ? placeholder : ""}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onKeyDown={handleInputKeyDown}
onFocus={() => {
setFocused(true);
setOpen(true);
}}
onBlur={() => {
setFocused(false);
// Delay close so option mousedown can register.
window.setTimeout(() => setOpen(false), 120);
}}
className={cn(
"relative z-10 min-w-24 flex-1 appearance-none bg-transparent px-1 py-1 text-sm",
"text-neutral-100 outline-none placeholder:text-neutral-500",
"disabled:cursor-not-allowed"
)}
/>
{/* Suggestion listbox. */}
<AnimatePresence>
{showList && (
<motion.ul
id={listboxId}
role="listbox"
aria-label={`${label} suggestions`}
initial={still ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={still ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={still ? { duration: 0 } : { duration: 0.14, ease: "easeOut" }}
className={cn(
"absolute left-0 right-0 top-full z-20 mt-2 max-h-56 overflow-auto rounded-xl p-1",
"border border-neutral-700 bg-neutral-900 shadow-2xl shadow-black/60"
)}
>
{matches.map((option, i) => {
const isActive = i === active;
return (
<li
key={option}
id={`${optionBaseId}-${i}`}
role="option"
aria-selected={isActive}
onMouseDown={(e) => {
// Keep focus on the input; add on click.
e.preventDefault();
if (addTag(option)) {
setQuery("");
setActive(-1);
setOpen(false);
inputRef.current?.focus();
}
}}
onMouseMove={() => setActive(i)}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm",
isActive ? "text-neutral-50" : "text-neutral-300"
)}
style={isActive ? { backgroundColor: `${accent}1f` } : undefined}
>
<span
aria-hidden
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{
backgroundColor: isActive ? accent : "#525252",
}}
/>
<span className="truncate">{option}</span>
</li>
);
})}
</motion.ul>
)}
</AnimatePresence>
</div>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/rosterManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string[] | Controlled list of tags. Omit for uncontrolled use (see defaultValue). | |
| defaultValue | string[] | [] | Initial tags when uncontrolled. |
| onChange | (tags: string[]) => void | Fires whenever the tag set changes, with the tags in order. | |
| suggestions | string[] | [] | Optional suggestion pool, filtered against the query as you type. Selecting one (click / Enter on the highlighted option) adds it. |
| max | number | undefined — no limit | Maximum number of tags. Adds are refused (and announced) at the cap. |
| placeholder | string | "Add tags…" | Placeholder shown in the empty input. |
| caseSensitive | boolean | false | Case-insensitive duplicate guard when false — "React" and "react" collide. |
| label | string | "Tags" | Accessible name for the field. |
| accent | string | sky | The single cool accent for focus ring, chips, and highlighted options. |
| disabled | boolean | false | Disable the whole control. |
| paused | boolean | false | Freeze the chip pop / removal motion (static resting pose). |
| className | string |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.