Multi Select
Composable multi-select primitives with searchable options, removable animated tokens, and a morphing collision-aware panel.
Preview
designengineering
TSXcomponents/previews/motion/multi-select.preview.tsx
"use client";
import { Circle } from "lucide-react";
import {
MultiSelect,
MultiSelectContent,
MultiSelectEmpty,
MultiSelectGroup,
MultiSelectInput,
MultiSelectItem,
MultiSelectLabel,
MultiSelectList,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/motion/multi-select";
const colors = {
design: "fill-rose-500 text-rose-500",
engineering: "fill-sky-500 text-sky-500",
product: "fill-amber-500 text-amber-500",
research: "fill-violet-500 text-violet-500",
marketing: "fill-emerald-500 text-emerald-500",
operations: "fill-slate-500 text-slate-500",
};
function Option({
value,
children,
}: {
value: keyof typeof colors;
children: string;
}) {
return (
<MultiSelectItem value={value} textValue={children}>
<span className="flex items-center gap-2.5">
<Circle aria-hidden="true" className={`size-2.5 ${colors[value]}`} />
{children}
</span>
</MultiSelectItem>
);
}
export function MultiSelectPreview() {
return (
<div className="flex min-h-[420px] w-full items-start justify-center px-4 pt-24">
<div className="w-full max-w-sm">
<MultiSelect defaultValue={["design", "engineering"]}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Choose teams" />
<MultiSelectInput aria-label="Search teams" />
</MultiSelectTrigger>
<MultiSelectContent>
<MultiSelectList ariaLabel="Teams">
<MultiSelectGroup>
<MultiSelectLabel>Product teams</MultiSelectLabel>
<Option value="design">Design</Option>
<Option value="engineering">Engineering</Option>
<Option value="product">Product</Option>
<Option value="research">Research</Option>
</MultiSelectGroup>
<MultiSelectGroup>
<MultiSelectLabel>Business teams</MultiSelectLabel>
<Option value="marketing">Marketing</Option>
<Option value="operations">Operations</Option>
</MultiSelectGroup>
<MultiSelectEmpty>No teams found.</MultiSelectEmpty>
</MultiSelectList>
</MultiSelectContent>
</MultiSelect>
</div>
</div>
);
}
TSXcomponents/motion/multi-select/index.tsx
"use client";
// beui.dev/components/motion/multi-select
export {
MultiSelect,
type MultiSelectFilter,
type MultiSelectProps,
} from "./context";
export {
MultiSelectInput,
type MultiSelectInputProps,
MultiSelectTrigger,
type MultiSelectTriggerProps,
MultiSelectValue,
type MultiSelectValueProps,
} from "./trigger";
export {
MultiSelectContent,
type MultiSelectContentProps,
} from "./content";
export {
MultiSelectEmpty,
type MultiSelectEmptyProps,
MultiSelectGroup,
type MultiSelectGroupProps,
MultiSelectItem,
type MultiSelectItemProps,
MultiSelectLabel,
type MultiSelectLabelProps,
MultiSelectList,
type MultiSelectListProps,
MultiSelectSeparator,
type MultiSelectSeparatorProps,
} from "./list";
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/multi-selectmulti-selectmulti-select
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
Copy the source code
TSXcomponents/motion/multi-select/index.tsx
"use client";
// beui.dev/components/motion/multi-select
export {
MultiSelect,
type MultiSelectFilter,
type MultiSelectProps,
} from "./context";
export {
MultiSelectInput,
type MultiSelectInputProps,
MultiSelectTrigger,
type MultiSelectTriggerProps,
MultiSelectValue,
type MultiSelectValueProps,
} from "./trigger";
export {
MultiSelectContent,
type MultiSelectContentProps,
} from "./content";
export {
MultiSelectEmpty,
type MultiSelectEmptyProps,
MultiSelectGroup,
type MultiSelectGroupProps,
MultiSelectItem,
type MultiSelectItemProps,
MultiSelectLabel,
type MultiSelectLabelProps,
MultiSelectList,
type MultiSelectListProps,
MultiSelectSeparator,
type MultiSelectSeparatorProps,
} from "./list";
TSXcomponents/motion/multi-select/context.tsx
"use client";
// beui.dev/components/motion/multi-select
import { useReducedMotion } from "motion/react";
import {
createContext,
type MutableRefObject,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useActiveOption } from "@/components/motion/combobox/use-active-option";
import { cn } from "@/lib/utils";
export type RegisteredMultiSelectItem = {
value: string;
label: string;
keywords: string[];
disabled: boolean;
groupId: string | null;
id: string;
ref: MutableRefObject<HTMLButtonElement | null>;
};
export type MultiSelectFilter = (
value: string,
query: string,
keywords: string[],
) => boolean;
const defaultFilter: MultiSelectFilter = (value, query, keywords) => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return true;
const haystack = [value, ...keywords].join(" ").toLocaleLowerCase();
let queryIndex = 0;
for (const character of haystack) {
if (character === needle[queryIndex]) queryIndex += 1;
if (queryIndex === needle.length) return true;
}
return false;
};
export type MultiSelectContextValue = {
open: boolean;
setOpen: (open: boolean, restoreFocus?: boolean) => void;
values: string[];
toggle: (value: string) => void;
remove: (value: string) => void;
query: string;
setQuery: (query: string) => void;
activeValue: string | null;
setActiveValue: (value: string | null) => void;
moveActive: (direction: 1 | -1 | "first" | "last") => void;
toggleActive: () => void;
registerItem: (item: RegisteredMultiSelectItem) => void;
unregisterItem: (value: string) => void;
labelFor: (value: string) => string;
isVisible: (value: string) => boolean;
hasVisibleItems: (groupId: string) => boolean;
visibleCount: number;
activeItemId: string | undefined;
triggerId: string;
listId: string;
inputId: string;
disabled: boolean;
reduce: boolean;
triggerRef: MutableRefObject<HTMLDivElement | null>;
contentRef: MutableRefObject<HTMLDivElement | null>;
inputRef: MutableRefObject<HTMLInputElement | null>;
activeLayoutId: string;
};
export const MultiSelectContext =
createContext<MultiSelectContextValue | null>(null);
export const MultiSelectGroupContext = createContext<string | null>(null);
export function useMultiSelectContext(component: string) {
const context = useContext(MultiSelectContext);
if (!context) {
throw new Error(`${component} must be used within <MultiSelect>`);
}
return context;
}
export function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object") {
(ref as MutableRefObject<T | null>).current = node;
}
}
};
}
export interface MultiSelectProps {
children: ReactNode;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
query?: string;
defaultQuery?: string;
onQueryChange?: (query: string) => void;
filter?: MultiSelectFilter;
disabled?: boolean;
className?: string;
}
export function MultiSelect({
children,
value: controlledValue,
defaultValue = [],
onValueChange,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
query: controlledQuery,
defaultQuery = "",
onQueryChange,
filter = defaultFilter,
disabled = false,
className,
}: MultiSelectProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [internalValue, setInternalValue] = useState(defaultValue);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [internalQuery, setInternalQuery] = useState(defaultQuery);
const [items, setItems] = useState<Map<string, RegisteredMultiSelectItem>>(
new Map(),
);
const valueControlled = controlledValue !== undefined;
const openControlled = controlledOpen !== undefined;
const queryControlled = controlledQuery !== undefined;
const values = controlledValue ?? internalValue;
const open = controlledOpen ?? internalOpen;
const query = controlledQuery ?? internalQuery;
const updateQuery = useCallback(
(next: string) => {
if (!queryControlled) setInternalQuery(next);
onQueryChange?.(next);
},
[onQueryChange, queryControlled],
);
const updateOpen = useCallback(
(next: boolean, restoreFocus = false) => {
if (disabled && next) return;
if (!openControlled) setInternalOpen(next);
onOpenChange?.(next);
if (!next) updateQuery("");
if (restoreFocus) {
requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true }),
);
}
},
[disabled, onOpenChange, openControlled, updateQuery],
);
const registerItem = useCallback((item: RegisteredMultiSelectItem) => {
setItems((current) => {
const existing = current.get(item.value);
if (
existing?.label === item.label &&
existing.disabled === item.disabled &&
existing.id === item.id &&
existing.ref === item.ref &&
existing.groupId === item.groupId &&
existing.keywords.join("\u0000") === item.keywords.join("\u0000")
) {
return current;
}
const next = new Map(current);
next.set(item.value, item);
return next;
});
}, []);
const unregisterItem = useCallback((itemValue: string) => {
setItems((current) => {
if (!current.has(itemValue)) return current;
const next = new Map(current);
next.delete(itemValue);
return next;
});
}, []);
const [openQuery, setOpenQuery] = useState(query);
if (open && openQuery !== query) setOpenQuery(query);
const listQuery = open ? query : openQuery;
const visibleItems = useMemo(
() =>
Array.from(items.values()).filter((item) =>
filter(item.value, listQuery, [item.label, ...item.keywords]),
),
[filter, items, listQuery],
);
const enabledVisibleItems = useMemo(
() => visibleItems.filter((item) => !item.disabled),
[visibleItems],
);
const visibleValues = useMemo(
() => new Set(visibleItems.map((item) => item.value)),
[visibleItems],
);
const visibleGroupIds = useMemo(
() => new Set(visibleItems.map((item) => item.groupId)),
[visibleItems],
);
const { activeValue, setActiveValue, moveActive } = useActiveOption({
open,
query: listQuery,
value: values[0],
enabledItems: enabledVisibleItems,
});
const commitValue = useCallback(
(next: string[]) => {
if (!valueControlled) setInternalValue(next);
onValueChange?.(next);
},
[onValueChange, valueControlled],
);
const toggle = useCallback(
(next: string) => {
if (items.get(next)?.disabled) return;
commitValue(
values.includes(next)
? values.filter((value) => value !== next)
: [...values, next],
);
updateQuery("");
requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true }),
);
},
[commitValue, items, updateQuery, values],
);
const remove = useCallback(
(itemValue: string) => {
if (!values.includes(itemValue)) return;
commitValue(values.filter((value) => value !== itemValue));
},
[commitValue, values],
);
const toggleActive = useCallback(() => {
if (activeValue) toggle(activeValue);
}, [activeValue, toggle]);
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true }),
);
return () => cancelAnimationFrame(frame);
}, [open]);
useEffect(() => {
if (!activeValue || !open) return;
const item = items.get(activeValue)?.ref.current;
const list = item?.closest<HTMLElement>("[role='listbox']");
if (!item || !list) return;
const itemRect = item.getBoundingClientRect();
const listRect = list.getBoundingClientRect();
if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top;
else if (itemRect.bottom > listRect.bottom) {
list.scrollTop += itemRect.bottom - listRect.bottom;
}
}, [activeValue, items, open]);
useEffect(() => {
if (!open) return;
const isInside = (target: Node) =>
rootRef.current?.contains(target) || contentRef.current?.contains(target);
const onPointerDown = (event: PointerEvent) => {
if (!isInside(event.target as Node)) updateOpen(false);
};
const onFocusIn = (event: FocusEvent) => {
if (!isInside(event.target as Node)) updateOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
updateOpen(false, true);
};
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("focusin", onFocusIn);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("focusin", onFocusIn);
window.removeEventListener("keydown", onKeyDown);
};
}, [open, updateOpen]);
const activeItem = activeValue ? items.get(activeValue) : undefined;
const context = useMemo<MultiSelectContextValue>(
() => ({
open,
setOpen: updateOpen,
values,
toggle,
remove,
query,
setQuery: updateQuery,
activeValue,
setActiveValue,
moveActive,
toggleActive,
registerItem,
unregisterItem,
labelFor: (itemValue) => items.get(itemValue)?.label ?? itemValue,
isVisible: (itemValue) => !listQuery.trim() || visibleValues.has(itemValue),
hasVisibleItems: (groupId) => visibleGroupIds.has(groupId),
visibleCount: visibleItems.length,
activeItemId: activeItem?.id,
triggerId: `${baseId}-trigger`,
listId: `${baseId}-list`,
inputId: `${baseId}-input`,
disabled,
reduce,
triggerRef,
contentRef,
inputRef,
activeLayoutId: `${baseId}-active`,
}),
[
activeItem?.id,
activeValue,
baseId,
disabled,
items,
listQuery,
moveActive,
open,
query,
reduce,
registerItem,
remove,
setActiveValue,
toggle,
toggleActive,
unregisterItem,
updateOpen,
updateQuery,
values,
visibleGroupIds,
visibleItems.length,
visibleValues,
],
);
return (
<MultiSelectContext.Provider value={context}>
<div ref={rootRef} className={cn("relative w-full", className)}>
{children}
</div>
</MultiSelectContext.Provider>
);
}
TSXcomponents/motion/multi-select/trigger.tsx
"use client";
// beui.dev/components/motion/multi-select
import { ChevronsUpDown, Search, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import type {
InputHTMLAttributes,
KeyboardEvent as ReactKeyboardEvent,
ReactNode,
Ref,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { mergeRefs, useMultiSelectContext } from "./context";
export interface MultiSelectTriggerProps {
children: ReactNode;
className?: string;
}
export function MultiSelectTrigger({
children,
className,
}: MultiSelectTriggerProps) {
const context = useMultiSelectContext("MultiSelectTrigger");
return (
<div
ref={context.triggerRef}
id={context.triggerId}
data-state={context.open ? "open" : "closed"}
onPointerDown={(event) => {
const target = event.target as HTMLElement;
if (
context.disabled ||
target === context.inputRef.current ||
target.closest("[data-multi-select-remove]")
) {
return;
}
event.preventDefault();
context.inputRef.current?.focus({ preventScroll: true });
context.setOpen(true);
}}
className={cn(
"relative z-20 flex min-h-11 w-full min-w-52 cursor-text items-center gap-2 rounded-xl border border-border bg-transparent px-2.5 py-1.5 text-sm text-foreground transition-[border-color] hover:border-(--color-border-strong)",
"focus-within:ring-2 focus-within:ring-foreground/20",
context.disabled && "pointer-events-none opacity-50",
className,
)}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{children}
</div>
<ChevronsUpDown
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
</div>
);
}
export interface MultiSelectValueProps {
placeholder?: ReactNode;
children?: (value: string, label: string) => ReactNode;
className?: string;
chipClassName?: string;
}
export function MultiSelectValue({
placeholder = "Select options",
children,
className,
chipClassName,
}: MultiSelectValueProps) {
const context = useMultiSelectContext("MultiSelectValue");
const showPlaceholder = context.values.length === 0 && !context.open;
return (
<div className={cn("contents", className)}>
<AnimatePresence initial={false} mode="popLayout">
{showPlaceholder ? (
<span key="multi-select-placeholder" className="text-muted-foreground">
{placeholder}
</span>
) : null}
{context.values.map((value) => {
const label = context.labelFor(value);
return (
<motion.span
layout={context.reduce ? false : "position"}
key={`multi-select-value-${value}`}
initial={{
opacity: 0,
clipPath: "inset(0 0 0 0% round 0.5rem)",
transform: context.reduce
? "translateY(0px) scale(1)"
: "translateY(6px) scale(0.92)",
}}
animate={{
opacity: 1,
clipPath: "inset(0 0 0 0% round 0.5rem)",
transform: "translateY(0px) scale(1)",
}}
exit={{
opacity: 1,
clipPath: context.reduce
? "inset(0 0 0 0% round 0.5rem)"
: "inset(0 0 0 100% round 0.5rem)",
transform: "translateY(0px) scale(1)",
transition: {
clipPath: context.reduce
? { duration: 0 }
: { duration: 0.16, ease: EASE_OUT },
transform: { duration: 0 },
},
}}
transition={
context.reduce
? {
layout: { duration: 0 },
opacity: { duration: 0.15, ease: EASE_OUT },
transform: { duration: 0 },
}
: {
layout: SPRING_LAYOUT,
opacity: { duration: 0.18, ease: EASE_OUT },
transform: SPRING_SWAP,
}
}
className={cn(
"inline-flex h-7 max-w-full items-center gap-1 rounded-lg bg-muted px-2 text-xs font-medium text-foreground",
chipClassName,
)}
>
<span className="truncate">
{children ? children(value, label) : label}
</span>
<button
type="button"
data-multi-select-remove=""
aria-label={`Remove ${label}`}
disabled={context.disabled}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
context.remove(value);
context.inputRef.current?.focus({ preventScroll: true });
}}
className="-mr-1 grid size-5 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden="true" className="size-3" />
</button>
</motion.span>
);
})}
</AnimatePresence>
</div>
);
}
export interface MultiSelectInputProps
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"defaultValue" | "value"
> {
ref?: Ref<HTMLInputElement>;
showIcon?: boolean;
}
export function MultiSelectInput({
ref,
className,
"aria-label": ariaLabel = "Search options",
onChange,
onClick,
onFocus,
onKeyDown,
onPointerDown,
placeholder = "Search…",
showIcon = false,
...props
}: MultiSelectInputProps) {
const context = useMultiSelectContext("MultiSelectInput");
const handleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "Backspace" && !context.query && context.values.length) {
event.preventDefault();
context.remove(context.values.at(-1) ?? "");
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!context.open) {
context.setOpen(true);
return;
}
context.moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Home" && context.open) {
event.preventDefault();
context.moveActive("first");
} else if (event.key === "End" && context.open) {
event.preventDefault();
context.moveActive("last");
} else if (event.key === "Enter") {
event.preventDefault();
if (context.open) context.toggleActive();
else context.setOpen(true);
} else if (event.key === "Escape" && context.open) {
event.preventDefault();
context.setOpen(false, true);
}
};
return (
<div className="flex min-w-20 flex-1 items-center gap-1.5">
{showIcon ? (
<Search aria-hidden="true" className="size-3.5 text-muted-foreground" />
) : null}
<input
{...props}
ref={mergeRefs(ref, context.inputRef)}
id={context.inputId}
role="combobox"
aria-label={ariaLabel}
aria-autocomplete="list"
aria-expanded={context.open}
aria-controls={context.listId}
aria-activedescendant={
context.open ? context.activeItemId : undefined
}
autoComplete="off"
disabled={context.disabled}
value={context.query}
placeholder={context.values.length ? "" : placeholder}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.defaultPrevented || context.open) return;
context.setOpen(true);
}}
onFocus={(event) => {
context.setOpen(true);
onFocus?.(event);
}}
onClick={(event) => {
context.setOpen(true);
onClick?.(event);
}}
onChange={(event) => {
context.setOpen(true);
context.setQuery(event.target.value);
onChange?.(event);
}}
onKeyDown={handleKeyDown}
className={cn(
"h-7 min-w-12 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
className,
)}
/>
</div>
);
}
TSXcomponents/motion/multi-select/content.tsx
"use client";
// beui.dev/components/motion/multi-select
import { motion, type Transition } from "motion/react";
import {
type CSSProperties,
type ReactNode,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { cn } from "@/lib/utils";
import { useMultiSelectContext } from "./context";
type Side = "top" | "bottom";
type Align = "start" | "center" | "end";
// Matches the Combobox surface: the field grows into a panel and then
// separates, preserving a continuous spatial relationship with its trigger.
const MULTI_SELECT_MORPH: Transition = {
type: "spring",
duration: 0.5,
bounce: 0.22,
};
const VIEWPORT_PADDING = 8;
export interface MultiSelectContentProps {
children: ReactNode;
side?: Side;
align?: Align;
sideOffset?: number;
avoidCollisions?: boolean;
className?: string;
}
export function MultiSelectContent({
children,
side = "bottom",
align = "start",
sideOffset = 6,
avoidCollisions = true,
className,
}: MultiSelectContentProps) {
const context = useMultiSelectContext("MultiSelectContent");
const measureRef = useRef<HTMLDivElement>(null);
const [portalReady, setPortalReady] = useState(false);
const [actualSide, setActualSide] = useState<Side>(side);
const [morphReady, setMorphReady] = useState(false);
const layout = usePopoverPortalPosition(
context.triggerRef,
measureRef,
portalReady,
);
useEffect(() => setPortalReady(true), []);
useLayoutEffect(() => {
if (!portalReady) return;
const readyFrame = requestAnimationFrame(() => setMorphReady(true));
return () => cancelAnimationFrame(readyFrame);
}, [portalReady]);
useLayoutEffect(() => {
if (!context.open || !layout) return;
if (!avoidCollisions) {
setActualSide(side);
return;
}
const below =
window.innerHeight - (layout.trigger.top + layout.trigger.height);
const above = layout.trigger.top;
if (
side === "bottom" &&
below < layout.content.height + sideOffset &&
above > below
) {
setActualSide("top");
} else if (
side === "top" &&
above < layout.content.height + sideOffset &&
below > above
) {
setActualSide("bottom");
} else {
setActualSide(side);
}
}, [avoidCollisions, context.open, layout, side, sideOffset]);
if (!portalReady) return null;
const triggerLeft = layout?.trigger.left ?? 0;
const triggerWidth = layout?.trigger.width ?? 0;
const contentWidth = layout?.content.width ?? triggerWidth;
const desiredLeft =
align === "end"
? triggerLeft + triggerWidth - contentWidth
: align === "center"
? triggerLeft + (triggerWidth - contentWidth) / 2
: triggerLeft;
const maxLeft = Math.max(
VIEWPORT_PADDING,
window.innerWidth - contentWidth - VIEWPORT_PADDING,
);
const left = Math.min(Math.max(desiredLeft, VIEWPORT_PADDING), maxLeft);
const surfaceHeight = layout?.content.height ?? 0;
return createPortal(
<motion.div
ref={context.contentRef}
data-multi-select-content=""
data-side={actualSide}
aria-hidden={!context.open}
inert={!context.open}
initial={false}
animate={{
height: context.open ? surfaceHeight : 0,
opacity: context.open ? 1 : 0,
y: context.open
? actualSide === "bottom"
? sideOffset
: -sideOffset
: 0,
}}
transition={
context.reduce || !morphReady ? { duration: 0 } : MULTI_SELECT_MORPH
}
style={
{
left,
top:
actualSide === "bottom" && layout
? layout.trigger.top + layout.trigger.height
: undefined,
bottom:
actualSide === "top" && layout
? window.innerHeight - layout.trigger.top
: undefined,
minWidth: triggerWidth,
pointerEvents: context.open ? "auto" : "none",
transformOrigin: actualSide === "bottom" ? "top" : "bottom",
visibility: layout ? "visible" : "hidden",
"--multi-select-trigger-width": `${triggerWidth}px`,
} as CSSProperties
}
className={cn(
"fixed z-[9999] w-(--multi-select-trigger-width) overflow-hidden rounded-xl border border-border bg-background text-popover-foreground outline-none will-change-[height,transform]",
className,
)}
>
<motion.div
ref={measureRef}
initial={false}
animate={{ opacity: context.open ? 1 : 0 }}
transition={
context.reduce || !morphReady ? { duration: 0 } : MULTI_SELECT_MORPH
}
>
{children}
</motion.div>
</motion.div>,
document.body,
);
}
TSXcomponents/motion/multi-select/list.tsx
"use client";
// beui.dev/components/motion/multi-select
import { Check } from "lucide-react";
import { motion } from "motion/react";
import {
type ReactNode,
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import {
MultiSelectGroupContext,
useMultiSelectContext,
} from "./context";
export interface MultiSelectListProps {
children: ReactNode;
ariaLabel?: string;
className?: string;
}
export function MultiSelectList({
children,
ariaLabel = "Options",
className,
}: MultiSelectListProps) {
const context = useMultiSelectContext("MultiSelectList");
return (
<div
id={context.listId}
role="listbox"
aria-label={ariaLabel}
aria-multiselectable="true"
className={cn(
"relative isolate max-h-64 overflow-y-auto overscroll-contain p-1.5 [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:hidden",
className,
)}
>
{children}
</div>
);
}
export interface MultiSelectGroupProps {
children: ReactNode;
className?: string;
}
export function MultiSelectGroup({
children,
className,
}: MultiSelectGroupProps) {
const context = useMultiSelectContext("MultiSelectGroup");
const groupId = useId();
return (
<MultiSelectGroupContext.Provider value={groupId}>
<fieldset
hidden={!context.hasVisibleItems(groupId)}
className={cn("m-0 min-w-0 border-0 p-0 py-0.5", className)}
>
{children}
</fieldset>
</MultiSelectGroupContext.Provider>
);
}
export interface MultiSelectLabelProps {
children: ReactNode;
className?: string;
}
export function MultiSelectLabel({
children,
className,
}: MultiSelectLabelProps) {
const groupId = useContext(MultiSelectGroupContext);
const labelClassName = cn(
"w-full px-2 py-1.5 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted-foreground",
className,
);
return groupId ? (
<legend className={labelClassName}>{children}</legend>
) : (
<div className={labelClassName}>{children}</div>
);
}
export interface MultiSelectItemProps {
value: string;
children: ReactNode;
textValue?: string;
keywords?: string[];
disabled?: boolean;
onSelect?: (value: string) => void;
className?: string;
}
export function MultiSelectItem({
value,
children,
textValue,
keywords = [],
disabled = false,
onSelect,
className,
}: MultiSelectItemProps) {
const context = useMultiSelectContext("MultiSelectItem");
const groupId = useContext(MultiSelectGroupContext);
const id = useId();
const itemRef = useRef<HTMLButtonElement>(null);
const label = textValue ?? (typeof children === "string" ? children : value);
const visible = context.isVisible(value);
const active = context.activeValue === value;
const selected = context.values.includes(value);
const keywordKey = keywords.join("\u0000");
const normalizedKeywords = useMemo(
() => (keywordKey ? keywordKey.split("\u0000") : []),
[keywordKey],
);
const { registerItem, unregisterItem } = context;
useLayoutEffect(() => {
registerItem({
value,
label,
keywords: normalizedKeywords,
disabled,
groupId,
id,
ref: itemRef,
});
return () => unregisterItem(value);
}, [
disabled,
groupId,
id,
label,
normalizedKeywords,
registerItem,
unregisterItem,
value,
]);
if (!visible) return null;
return (
<button
ref={itemRef}
id={id}
type="button"
role="option"
aria-selected={selected}
disabled={disabled}
tabIndex={-1}
data-multi-select-item=""
data-active={active || undefined}
data-selected={selected || undefined}
onPointerMove={() => {
if (!disabled) context.setActiveValue(value);
}}
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
if (disabled) return;
onSelect?.(value);
context.toggle(value);
}}
className={cn(
"relative flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm outline-none transition-colors duration-150",
active || selected ? "text-foreground" : "text-muted-foreground",
"disabled:pointer-events-none disabled:opacity-45",
className,
)}
>
{active ? (
<motion.span
aria-hidden="true"
layoutId={context.activeLayoutId}
className="absolute inset-0 -z-10 rounded-lg bg-muted"
transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}
/>
) : null}
<span className="min-w-0 flex-1">{children}</span>
<motion.span
aria-hidden="true"
initial={false}
animate={{
opacity: selected ? 1 : 0,
transform: selected ? "scale(1)" : "scale(0.82)",
}}
transition={
context.reduce ? { duration: 0 } : { duration: 0.14, ease: EASE_OUT }
}
className="grid size-5 shrink-0 place-items-center text-foreground"
>
<Check className="size-4" />
</motion.span>
</button>
);
}
export interface MultiSelectEmptyProps {
children?: ReactNode;
className?: string;
}
export function MultiSelectEmpty({
children = "No options found.",
className,
}: MultiSelectEmptyProps) {
const context = useMultiSelectContext("MultiSelectEmpty");
if (context.visibleCount > 0) return null;
return (
<div
role="status"
className={cn(
"px-3 py-8 text-center text-sm text-muted-foreground",
className,
)}
>
{children}
</div>
);
}
export interface MultiSelectSeparatorProps {
className?: string;
}
export function MultiSelectSeparator({
className,
}: MultiSelectSeparatorProps) {
return (
<div aria-hidden="true" className={cn("-mx-1 my-1 h-px bg-border", className)} />
);
}
TSXcomponents/motion/combobox/use-active-option.ts
"use client";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
/**
* Where the keyboard or the pointer last moved to, stamped with the query it
* was placed under. Which option is *active* is resolved from this during
* render, never in an effect: a passive effect runs after the commit, so a list
* would briefly have none of its options active, and a key arriving in that
* window would move from nowhere onto the row it was already about to
* highlight.
*/
type ActiveCursor = { value: string; query: string };
type Options = {
query: string;
value: string | undefined;
/** The enabled, visible options in list order — all this hook reads of them. */
enabledItems: readonly { value: string }[];
};
const isEnabled = (
enabledItems: Options["enabledItems"],
candidate: string | undefined,
): candidate is string =>
candidate !== undefined && enabledItems.some((i) => i.value === candidate);
/**
* The cursor's option, or null once the query or the result set it was placed
* in has changed. A cursor that outlived either would steal Enter from the row
* the user is aiming at. The result-set half costs something: a live search
* that blanks its rows while fetching and returns the same ones loses the moved
* highlight. That is deliberate — a highlight visibly back at the top beats one
* silently in the wrong place.
*
* It is stamped with the query rather than with the identity of the visible
* list because callers routinely pass an inline `filter`, which makes that list
* a fresh array on every render.
*/
function liveCursorValue(cursor: ActiveCursor | null, options: Options) {
if (cursor === null || cursor.query !== options.query) return null;
return isEnabled(options.enabledItems, cursor.value) ? cursor.value : null;
}
/**
* Where the highlight sits with no live cursor: the selection if it can be
* selected, otherwise the first option that can. Only enabled options qualify —
* an active disabled option would point `aria-activedescendant` at a row Enter
* then refuses to select.
*/
function fallbackActive({ value, enabledItems }: Options) {
return isEnabled(enabledItems, value) ? value : (enabledItems[0]?.value ?? null);
}
/** The active option, from a cursor that may or may not still be live. */
const resolveActive = (cursor: ActiveCursor | null, options: Options) =>
liveCursorValue(cursor, options) ?? fallbackActive(options);
export function useActiveOption({ open, ...options }: Options & { open: boolean }) {
const { query, value, enabledItems } = options;
const [cursor, setCursor] = useState<ActiveCursor | null>(null);
const live = liveCursorValue(cursor, options);
// Cleared rather than ignored: React re-runs this render with the cursor
// gone, so a value that reappears later cannot revive it.
if (cursor !== null && live === null) setCursor(null);
const derived = live ?? fallbackActive(options);
// Nothing is active until the list has been opened once. After that the
// resolution above is already stable across a close — the list keeps
// filtering by the query it was open with — so the highlight holds its row
// through the exit without being frozen separately.
const [opened, setOpened] = useState(open);
if (open && !opened) setOpened(true);
const activeValue = opened ? derived : null;
// Both callbacks keep one identity for the life of the component, and read
// the list through a ref to do it. A caller will put them in a `useMemo` or
// an effect's dependencies — the exhaustive-deps rule makes it — and
// `enabledItems` is a fresh array on every render for any consumer passing an
// inline `filter`, so a callback keyed to it would be rebuilt every render.
// Written after commit rather than during render: a render React discards
// still runs the component body, and a handler reading this in that window
// would step against a list the committed tree does not have.
const latest = useRef({ open, query, value, enabledItems });
useLayoutEffect(() => {
latest.current = { open, query, value, enabledItems };
});
const setActiveValue = useCallback((next: string | null) => {
setCursor(
next === null ? null : { value: next, query: latest.current.query },
);
}, []);
// Steps from the option the cursor really resolves to, inside the update, so
// that two keys landing in one batch move two rows rather than one.
const moveActive = useCallback(
(direction: 1 | -1 | "first" | "last") => {
const options = latest.current;
// While closed the list is still filtering by the query it was open
// with, so a step taken now would be measured against rows the next
// render replaces. Opening is the caller's job; stepping waits for it.
if (!options.open) return;
const rows = options.enabledItems;
const last = rows.length - 1;
if (last < 0) {
setCursor(null);
return;
}
setCursor((current) => {
// `resolveActive` always lands on a member of `enabledItems` once the
// list is non-empty, which the early return above guarantees, so there
// is always a row to step from.
const from = resolveActive(current, options);
const at = rows.findIndex((item) => item.value === from);
const index =
direction === "first"
? 0
: direction === "last"
? last
: (at + direction + rows.length) % rows.length;
return { value: rows[index].value, query: options.query };
});
},
[],
);
return { activeValue, setActiveValue, moveActive };
}
TSXcomponents/motion/popover-position.ts
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
API Reference
MultiSelect
value?string[]—defaultValue?string[][]onValueChange?((value: string[]) => void)—open?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—query?string—defaultQuery?stringonQueryChange?((query: string) => void)—filter?MultiSelectFilter(value, query, keywords) => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return true;
const haystack = [value, ...keywords].join(" ").toLocaleLowerCase();
let queryIndex = 0;
for (const character of haystack) {
if (character === needle[queryIndex]) queryIndex += 1;
if (queryIndex === needle.length) return true;
}
return false;
}disabled?booleanfalseclassName?string—MultiSelectInput
ref?Ref<HTMLInputElement>—showIcon?booleanfalseclassName?string—MultiSelectTrigger
className?string—MultiSelectValue
placeholder?ReactNodeSelect optionsclassName?string—chipClassName?string—MultiSelectContent
side?"bottom" | "top"bottomalign?"start" | "end" | "center"startsideOffset?number6avoidCollisions?booleantrueclassName?string—MultiSelectEmpty
className?string—MultiSelectGroup
className?string—MultiSelectItem
valuestring—textValue?string—keywords?string[][]disabled?booleanfalseonSelect?((value: string) => void)—className?string—MultiSelectLabel
className?string—MultiSelectList
ariaLabel?stringOptionsclassName?string—MultiSelectSeparator
className?string—Related components
Combobox
Searchable combobox with a morphing portal, grouped filtering, keyboard navigation, and controlled or uncontrolled state.
Select
Composable select primitives whose panel unfolds out of the trigger on one spring and pulls away into its own pill, plus a Morph variant where the trigger grows into the panel via shared layout.
Tooltip
Composable hover, focus and tap tooltip with a subtle scale and fade, collision-aware side and alignment, an optional arrow, a pointer corridor onto hoverable content, one label at a time, keyboard-only focus reveal and optional cursor tracking.
Updated