# Roster — Animated Input for React

> 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.

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

## Install

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

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

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

## Props (`RosterProps`)

| 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 the field + chip focus rings and highlighted options. |
| `disabled?` | `boolean` | `false` | Disable the whole control. |
| `paused?` | `boolean` | `false` | Freeze the chip pop / removal motion (static resting pose). |
| `className?` | `string` | — | — |

## Usage example

```tsx
"use client";

import * as React from "react";

import { Roster } from "@/components/crucible/roster";

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

const SUGGESTIONS = [
  "TypeScript",
  "React",
  "Next.js",
  "Tailwind",
  "Motion",
  "GraphQL",
  "Rust",
  "Go",
  "Postgres",
  "Redis",
  "Docker",
  "Kubernetes",
];

export default function RosterDemo() {
  const [tags, setTags] = React.useState<string[]>(["TypeScript", "React"]);

  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-md flex-col gap-3 px-6">
        <span className={LABEL}>Skills</span>
        <Roster
          value={tags}
          onChange={setTags}
          suggestions={SUGGESTIONS}
          max={8}
          label="Skills"
          placeholder="Add a skill…"
        />
        <p className="text-sm text-neutral-500">
          Add tags — type and press Enter (or comma). Pick from suggestions with
          ↑ ↓ and Enter. Backspace on an empty field removes the last chip.
        </p>
        <span className="h-5 text-xs text-neutral-600">
          {tags.length > 0 ? (
            <>
              <span className="text-neutral-300">{tags.length}</span> selected
            </>
          ) : (
            "No tags yet"
          )}
        </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 (`roster.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 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 the field + chip focus rings 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 blurTimer = React.useRef<number | undefined>(undefined);

  // Clear the delayed list-close on unmount — no dangling timer.
  React.useEffect(() => () => window.clearTimeout(blurTimer.current), []);

  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;

  /** Add one or more tags in a single commit; returns true if any were added. */
  const addTags = React.useCallback(
    (raws: string[]): boolean => {
      const next = [...tags];
      const added: string[] = [];
      let refusal = "";
      for (const raw of raws) {
        const trimmed = raw.trim();
        if (!trimmed) continue;
        if (max != null && next.length >= max) {
          refusal = `Maximum of ${max} tags reached`;
          break;
        }
        const n = normalize(trimmed, caseSensitive);
        if (next.some((t) => normalize(t, caseSensitive) === n)) {
          refusal = `${trimmed} is already added`;
          continue;
        }
        next.push(trimmed);
        added.push(trimmed);
      }
      if (added.length > 0) commit(next);
      const parts = [added.length ? `Added ${added.join(", ")}` : "", refusal].filter(Boolean);
      if (parts.length) setAnnouncement(parts.join(". "));
      return added.length > 0;
    },
    [caseSensitive, commit, max, tags]
  );

  const addTag = React.useCallback((raw: string) => addTags([raw]), [addTags]);

  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({ preventScroll: true });
    else chipRefs.current.get(target)?.focus({ preventScroll: true });
  }, [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({ preventScroll: true });
      else inputRef.current?.focus({ preventScroll: true });
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      const prev = tags[index - 1];
      if (prev) chipRefs.current.get(prev)?.focus({ preventScroll: true });
    }
  };

  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({ preventScroll: true })}
        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 relative 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) => {
            const v = e.target.value;
            // Mobile keyboards (no reliable "," keydown) and pastes like
            // "a, b, c" deliver commas here: commit every finished segment and
            // keep the trailing remainder as the live query.
            if (v.includes(",")) {
              const parts = v.split(",");
              const rest = parts.pop() ?? "";
              addTags(parts);
              setQuery(rest);
              setActive(-1);
            } else {
              setQuery(v);
            }
            setOpen(true);
          }}
          onKeyDown={handleInputKeyDown}
          onFocus={() => {
            window.clearTimeout(blurTimer.current);
            setFocused(true);
            setOpen(true);
          }}
          onBlur={() => {
            setFocused(false);
            // Delay close so option mousedown can register.
            window.clearTimeout(blurTimer.current);
            blurTimer.current = 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({ preventScroll: true });
                      }
                    }}
                    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>
  );
}
```
