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-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?{}—defaultValue?{}[]onValueChange?((value: {}) => 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?any—showIcon?booleanfalseMultiSelectTrigger
className?string—MultiSelectValue
placeholder?anySelect optionsclassName?string—chipClassName?string—MultiSelectContent
side?"top" | "bottom"bottomalign?"start" | "center" | "end"startsideOffset?number6avoidCollisions?booleantrueclassName?string—MultiSelectEmpty
className?string—MultiSelectGroup
className?string—MultiSelectItem
valuestring—textValue?string—keywords?{}[]disabled?booleanfalseonSelect?((value: string) => void)—className?string—MultiSelectLabel
className?string—MultiSelectList
ariaLabel?stringOptionsclassName?string—MultiSelectSeparator
className?string—Updated