Filter Tags
A horizontally scrollable multi-select tag row that groups selected tags first in selection order, with responsive controls and reduced-motion support.
formlayout-reorder
"use client";
import * as React from "react";
import { Toggle } from "@base-ui/react/toggle";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { motion, useReducedMotion, type Transition } from "motion/react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type FilterTagItem = {
/** Unique, stable identifier. Duplicate values produce duplicate React keys. */
value: string;
label: React.ReactNode;
disabled?: boolean;
};
export type FilterTagsClassNames = Partial<Record<
"row" | "viewport" | "list" | "tag" | "arrow" | "backwardArrow" | "forwardArrow",
string
>>;
export type FilterTagsArrows = {
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
backwardIcon?: React.ReactNode;
forwardIcon?: React.ReactNode;
backwardLabel?: string;
forwardLabel?: string;
};
export type FilterTagsProps = Omit<
React.ComponentProps<"div">,
"children" | "defaultValue" | "onChange"
> & {
items: FilterTagItem[];
/**
* Selected values in selection order. An empty array clears selection.
* Values that don't match an item are ignored and dropped from the next
* `onValueChange` payload.
*/
value?: string[];
defaultValue?: string[];
/** Receives the full next selection and the tag that was toggled. */
onValueChange?: (value: string[], item: FilterTagItem) => void;
disabled?: boolean;
/** Transition for the reorder animation when a tag moves to or from the selected group. */
transition?: Transition;
/** Hide the native scrollbar without disabling touch/trackpad scrolling. */
showScrollbar?: boolean;
/**
* true: always; false: never; auto: only when a fine pointer (mouse or
* trackpad) is available, since touch users scroll the row directly.
*/
showArrows?: boolean | "auto";
classNames?: FilterTagsClassNames;
arrows?: FilterTagsArrows;
};
/** Ease-out: a tag should start moving the instant it is toggled. */
const REORDER_TRANSITION: Transition = {
duration: 0.2,
ease: [0.22, 1, 0.36, 1],
};
/** Breathing room kept between a revealed tag and the viewport edge. */
const REVEAL_MARGIN = 4;
/**
* Tag height per input modality (44px touch target, 36px with a fine pointer)
* and the list's vertical padding. The arrow rails derive their height from
* both, so they stay centred on the tags until the ResizeObserver takes over.
*/
const SIZE_VARS =
"[--filter-tags-list-py:0.5rem] [--filter-tags-tag-h:2.75rem] pointer-fine:[--filter-tags-tag-h:2.25rem]";
function useOrderedSelection(
items: FilterTagItem[],
value: string[] | undefined,
defaultValue: string[],
) {
const [internalValue, setInternalValue] = React.useState(defaultValue);
const isControlled = value !== undefined;
const sourceValue = isControlled ? value : internalValue;
const byValue = React.useMemo(
() => new Map(items.map((item) => [item.value, item])),
[items],
);
const selected = React.useMemo(
() => [...new Set(sourceValue)].filter((id) => byValue.has(id)),
[sourceValue, byValue],
);
const selectedSet = React.useMemo(() => new Set(selected), [selected]);
const orderedItems = React.useMemo(
() => [
...selected.map((id) => byValue.get(id)!),
...items.filter((item) => !selectedSet.has(item.value)),
],
[items, selected, selectedSet, byValue],
);
return { isControlled, selected, selectedSet, orderedItems, setInternalValue };
}
function useScrollEdges(scrollerRef: React.RefObject<HTMLElement | null>) {
const [edges, setEdges] = React.useState({ backward: false, forward: false });
const measure = React.useCallback(() => {
const node = scrollerRef.current;
if (!node) return;
const backward = node.scrollLeft > 1;
const forward = node.scrollLeft + node.clientWidth < node.scrollWidth - 1;
setEdges((previous) =>
previous.backward === backward && previous.forward === forward
? previous
: { backward, forward },
);
}, [scrollerRef]);
return { edges, measure };
}
function isKeyboardEvent(event: Event | undefined) {
return event?.type === "keydown" || event?.type === "keyup" || (event as MouseEvent | undefined)?.detail === 0;
}
/**
* Multi-select filters: selected tags lead in selection order; deselection
* restores source order.
*
* The row always lays out left-to-right, even in RTL documents, so the
* selected group and the backward arrow sit on the physical left. Labels
* still resolve their own direction via `dir="auto"`.
*/
export function FilterTags({
items,
value,
defaultValue = [],
onValueChange,
disabled = false,
transition = REORDER_TRANSITION,
className,
showScrollbar = false,
showArrows = "auto",
classNames,
arrows,
"aria-label": ariaLabel = "Filters",
"aria-labelledby": ariaLabelledBy,
...props
}: FilterTagsProps) {
const {
variant: arrowVariant = "ghost",
size: arrowSize = "icon",
backwardIcon = <ChevronLeft aria-hidden="true" />,
forwardIcon = <ChevronRight aria-hidden="true" />,
backwardLabel = "Scroll tags left",
forwardLabel = "Scroll tags right",
} = arrows ?? {};
const reduceMotion = useReducedMotion();
const scrollerRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const tagRefs = React.useRef(new Map<string, HTMLDivElement>());
const [listHeight, setListHeight] = React.useState<number>();
// Keyboard activation reorders and scrolls instantly: the focused tag stays
// put under its focus ring and the reveal below reads settled coordinates.
const [instant, setInstant] = React.useState(false);
// The tag currently travelling between groups. A selected mover rides above
// every other tag; a deselected one slips back underneath, like a raised
// layer settling down.
const [flying, setFlying] = React.useState<string>();
const { isControlled, selected, selectedSet, orderedItems, setInternalValue } =
useOrderedSelection(items, value, defaultValue);
const { edges, measure: measureEdges } = useScrollEdges(scrollerRef);
const previousSelected = React.useRef(selected);
const previousOrder = React.useRef(orderedItems);
// Motion re-measures every tag whenever this changes and nowhere else, so
// parent re-renders, scrollbar toggles, and resizes never animate the row.
const layoutDependency = orderedItems.map((item) => item.value).join("