Selector
A searchable selector for models, people, workspaces and visual resources. Single or multiple selection, grouped lists or thumbnail grids, optional details, removable chips and keyboard navigation share one morphing surface.
Models · Single select
selector.tsxUse options with id and name; group, description, metadata, keywords, icon and details are optional. Control a single ID with value/onValueChange or initialize it with defaultValue. Search names, descriptions, groups, metadata and keywords; use arrow keys or Page Up/Down and Enter to choose, Escape to close. Hover or focus a row for its optional details. No size presets: className styles the outer wrapper; classNames provides trigger, content, list, option, icon, search and details overrides, merged after defaults. For example, classNames={{ content: 'w-[28rem]', trigger: 'min-h-9 px-2 text-xs', list: 'max-h-80' }} independently adjusts panel width, trigger density and list height. Viewport bounds remain in place. The existing @beui/model-selector install address remains available; the exported component is now Selector.
Choose your model
Using Claude Sonnet.
Search by model, provider, or capability. Hover or focus Claude Sonnet for details. Customize the trigger and panel with classNames.
"use client";
import { Bot, Brain, Building2, Circle, Sparkles } from "lucide-react";
import { useState } from "react";
import { Selector } from "@/components/motion/selector";
import type { SelectorOption } from "@/components/motion/selector";
const MODELS: SelectorOption[] = [
{
id: "claude-sonnet",
name: "Claude Sonnet",
group: "Anthropic",
description: "Balanced reasoning and everyday coding",
metadata: "200K",
keywords: ["reasoning", "vision", "tools"],
icon: <Brain className="size-4"/>,
details: (<div className="space-y-2">
<p className="font-medium">Claude Sonnet</p>
<p className="text-sm text-muted-foreground">
A balanced choice for writing, analysis, and everyday coding. Supports
images and tools with a 200K-token context window.
</p>
</div>),
},
{
id: "claude-opus",
name: "Claude Opus",
group: "Anthropic",
description: "Deep reasoning for complex work",
metadata: "200K",
keywords: ["reasoning", "analysis"],
icon: <Brain className="size-4"/>,
},
{
id: "gpt-4o",
name: "GPT-4o",
group: "OpenAI",
description: "Fast, multimodal conversations",
metadata: "128K",
keywords: ["vision", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gpt-4.1",
name: "GPT-4.1",
group: "OpenAI",
description: "Precise instructions and a larger context",
metadata: "1M",
keywords: ["coding", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gemini-pro",
name: "Gemini Pro",
group: "Google",
description: "Reason across documents, images, and code",
metadata: "1M",
keywords: ["vision", "reasoning"],
icon: <Bot className="size-4"/>,
},
];
export function SelectorPreview() {
const [model, setModel] = useState("claude-sonnet");
return (<div className="w-full max-w-md space-y-4">
<p className="font-medium text-sm">Choose your model</p>
<Selector options={MODELS} value={model} onValueChange={setModel} triggerVariant="pill" label="Models" searchPlaceholder="Search models…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Using {MODELS.find((option) => option.id === model)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Search by model, provider, or capability. Hover or focus Claude Sonnet
for details. Customize the trigger and panel with classNames.
</p>
</div>);
}
const PEOPLE: SelectorOption[] = [
{ id: "ava", name: "Ava Thompson", description: "ava@studio.co", metadata: "Design", group: "Your team" },
{ id: "noah", name: "Noah Williams", description: "noah@studio.co", metadata: "Engineering", group: "Your team" },
{ id: "mia", name: "Mia Chen", description: "mia@studio.co", metadata: "Product", group: "Your team" },
{ id: "leo", name: "Leo Martin", description: "leo@partner.co", metadata: "Guest", group: "Collaborators" },
].map((person) => ({
...person,
icon: (<span className="flex size-7 items-center justify-center rounded-full border bg-muted text-[10px] font-medium">
{person.name.split(" ").map((part) => part[0]).join("")}
</span>),
}));
export function SelectorPeopleSinglePreview() {
const [person, setPerson] = useState("");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Choose an assignee</p>
<p className="text-xs text-muted-foreground">
Search by name or email and choose one person responsible for the task.
</p>
</div>
<Selector options={PEOPLE} value={person} onValueChange={setPerson} placeholder="Assign to someone" searchPlaceholder="Search people…" label="Assignee"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{person ? `Assigned to ${PEOPLE.find((option) => option.id === person)?.name}.` : "No assignee yet."}
</p>
</div>);
}
export function SelectorPeoplePreview() {
const [people, setPeople] = useState<string[]>(["ava", "mia"]);
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Share with your team</p>
<p className="text-xs text-muted-foreground">
Search names or emails, then select everyone who needs access.
</p>
</div>
<Selector multiple options={PEOPLE} value={people} onValueChange={setPeople} triggerVariant="field" placeholder="Add people" searchPlaceholder="Search people…" label="People"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{people.length === 0
? "Only you have access."
: `Shared with ${people.length} ${people.length === 1 ? "person" : "people"}.`}
</p>
<p className="text-xs text-muted-foreground">
Remove a chip to revoke access, or choose Done when finished.
</p>
</div>);
}
const WORKSPACES: SelectorOption[] = [
{
id: "studio",
name: "Studio",
description: "Pro plan · 12 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "engineering",
name: "Engineering",
description: "Pro plan · 8 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "personal",
name: "Personal",
description: "Free plan · Just you",
group: "Personal workspaces",
icon: <Circle className="size-4"/>,
},
];
export function SelectorWorkspacePreview() {
const [options, setOptions] = useState(WORKSPACES);
const [workspace, setWorkspace] = useState("studio");
function createWorkspace() {
const number = options.length - WORKSPACES.length + 1;
const id = `new-workspace-${number}`;
setOptions([
...options,
{
id,
name: `New workspace ${number}`,
description: "Free plan · Just you",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
]);
setWorkspace(id);
}
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Your work, in one place</p>
<p className="text-xs text-muted-foreground">
Switch between team and personal workspaces.
</p>
</div>
<Selector options={options} value={workspace} onValueChange={setWorkspace} triggerVariant="workspace" searchPlaceholder="Find a workspace…" label="Workspaces" action={{ label: "Create workspace", onSelect: createWorkspace }}/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Current workspace: {options.find((option) => option.id === workspace)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Create workspace adds a new workspace and switches to it immediately.
New workspaces live in this demo until the page is refreshed.
</p>
</div>);
}
function TemplateThumbnail({ variant }: {
variant: "dashboard" | "editorial" | "board" | "portfolio";
}) {
return (<div aria-hidden="true" className="flex aspect-[16/10] w-full overflow-hidden rounded-lg border bg-background p-2 text-foreground">
{variant === "dashboard" ? (<>
<div className="mr-2 w-1/5 space-y-2 border-r pr-2">
<div className="h-2 w-3 rounded-sm bg-foreground/60"/>
{[0, 1, 2, 3].map((item) => <div key={item} className="h-1 rounded bg-foreground/15"/>)}
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="h-1.5 w-1/2 rounded bg-foreground/40"/>
<div className="grid grid-cols-3 gap-1">
{[0, 1, 2].map((item) => <div key={item} className="h-5 rounded border bg-foreground/5"/>)}
</div>
<div className="flex flex-1 items-end gap-1 rounded border p-1">
{[30, 55, 40, 75, 60, 90, 70].map((height) => <div key={height} className="flex-1 rounded-t-sm bg-foreground/25" style={{ height: `${height}%` }}/>)}
</div>
</div>
</>) : variant === "editorial" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1 w-full border-b"/>
<div className="mx-auto h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="mx-auto h-1 w-1/3 rounded bg-foreground/20"/>
<div className="flex flex-1 gap-2">
<div className="w-1/2 rounded bg-foreground/10"/>
<div className="flex-1 space-y-1.5 pt-1">
{[0, 1, 2, 3, 4].map((item) => <div key={item} className="h-1 rounded bg-foreground/15 last:w-2/3"/>)}
</div>
</div>
</div>) : variant === "board" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1.5 w-1/3 rounded bg-foreground/50"/>
<div className="grid flex-1 grid-cols-3 gap-1.5">
{[2, 3, 1].map((count) => (<div key={count} className="space-y-1 rounded bg-foreground/5 p-1">
<div className="mb-2 h-1 w-2/3 rounded bg-foreground/25"/>
{["first", "second", "third"].slice(0, count).map((card) => <div key={card} className="h-4 rounded-sm border bg-background"/>)}
</div>))}
</div>
</div>) : (<div className="flex w-full flex-col gap-2">
<div className="flex justify-between"><div className="h-1.5 w-6 rounded bg-foreground/50"/><div className="h-1 w-8 rounded bg-foreground/15"/></div>
<div className="mt-1 h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="h-1 w-1/2 rounded bg-foreground/20"/>
<div className="grid flex-1 grid-cols-2 gap-2"><div className="rounded bg-foreground/10"/><div className="rounded bg-foreground/20"/></div>
</div>)}
</div>);
}
const RESOURCES: SelectorOption[] = [
{ id: "dashboard", name: "Dashboard", description: "Metrics at a glance", metadata: "12 blocks", preview: <TemplateThumbnail variant="dashboard"/>, keywords: ["analytics", "charts"] },
{ id: "editorial", name: "Editorial", description: "Stories worth reading", metadata: "8 blocks", preview: <TemplateThumbnail variant="editorial"/>, keywords: ["blog", "writing"] },
{ id: "board", name: "Project board", description: "A little more organized", metadata: "6 blocks", preview: <TemplateThumbnail variant="board"/>, keywords: ["kanban", "tasks"] },
{ id: "portfolio", name: "Portfolio", description: "Make your work the focus", metadata: "10 blocks", preview: <TemplateThumbnail variant="portfolio"/>, keywords: ["gallery", "personal"] },
];
export function SelectorResourcesPreview() {
const [resource, setResource] = useState("dashboard");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Start with a template</p>
<p className="text-xs text-muted-foreground">
Browse the previews or search for a layout that fits your next project.
</p>
</div>
<Selector options={RESOURCES} value={resource} onValueChange={setResource} layout="grid" triggerVariant="field" classNames={{ content: "w-[30rem]", list: "max-h-[26rem]" }} label="Templates" searchPlaceholder="Search templates…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Selected template: {RESOURCES.find((option) => option.id === resource)?.name}.
</p>
</div>);
}
"use client";
// asharca.github.io/ui/components/blocks/selector
import { Check, ChevronDown, Search, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { SPRING_PRESS } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
export type SelectorOption = {
id: string;
name: string;
description?: string;
group?: string;
metadata?: string;
keywords?: string[];
icon?: ReactNode;
preview?: ReactNode;
details?: ReactNode;
disabled?: boolean;
};
/** Optional Tailwind overrides, merged after each slot's defaults. */
export type SelectorClassNames = {
trigger?: string;
content?: string;
list?: string;
option?: string;
icon?: string;
search?: string;
details?: string;
};
type SharedSelectorProps = {
options: SelectorOption[];
defaultOpen?: boolean;
placeholder?: string;
searchPlaceholder?: string;
label?: string;
triggerVariant?: "pill" | "field" | "workspace";
layout?: "list" | "grid";
action?: { label: string; onSelect: () => void };
className?: string;
/** Style individual parts independently; className styles the outer wrapper. */
classNames?: SelectorClassNames;
};
export type SelectorProps = SharedSelectorProps & (
| {
multiple?: false;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
| {
multiple: true;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
}
);
const SCROLLBAR_CLASS =
"[scrollbar-width:thin] [scrollbar-gutter:stable] [scrollbar-color:color-mix(in_oklab,var(--foreground)_18%,transparent)_transparent] hover:[scrollbar-color:color-mix(in_oklab,var(--foreground)_30%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb:hover]:bg-foreground/30";
export function Selector(props: SelectorProps) {
const {
options,
defaultOpen = false,
placeholder = "Select an option",
searchPlaceholder = "Search options",
label = "Options",
triggerVariant = "field",
layout = "list",
action,
className,
classNames,
} = props;
const uid = useId();
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState<string | string[]>(() =>
props.defaultValue ?? (props.multiple ? [] : options.find((option) => !option.disabled)?.id ?? ""),
);
const currentValue = props.value ?? internalValue;
const latestValue = useRef(currentValue);
useLayoutEffect(() => {
latestValue.current = currentValue;
});
const selectedIds = new Set(Array.isArray(currentValue) ? currentValue : [currentValue]);
const selectedOptions = options.filter((option) => !option.disabled && selectedIds.has(option.id));
const selected = selectedOptions[0];
const [open, setOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const detailRef = useRef<HTMLElement>(null);
const detailAnchor = useRef<HTMLElement | null>(null);
const detailTimer = useRef<number | undefined>(undefined);
const touchInteraction = useRef(false);
const [detailId, setDetailId] = useState<string | null>(null);
const [detailPosition, setDetailPosition] = useState({ top: 12, left: 12 });
const groups = useMemo(() => {
const result = new Map<string, SelectorOption[]>();
const search = query.trim().toLowerCase();
for (const option of options) {
if (![option.name, option.description, option.group, option.metadata, ...(option.keywords ?? [])]
.filter(Boolean).join(" ").toLowerCase().includes(search)) continue;
const group = option.group ?? "";
const items = result.get(group) ?? [];
items.push(option);
result.set(group, items);
}
return Array.from(result, ([label, items]) => ({ label, items }));
}, [options, query]);
const rows = useMemo(() => groups.flatMap((group) => group.items).filter((option) => !option.disabled), [groups]);
const { activeIndex, moveTo, moveActive } = useRowCursor(rows, query);
const active = rows[activeIndex];
const detail = open ? rows.find((option) => option.id === detailId && option.details != null) : undefined;
const clearDetailTimer = () => {
window.clearTimeout(detailTimer.current);
detailTimer.current = undefined;
};
const hideDetail = () => {
clearDetailTimer();
setDetailId(null);
};
const scheduleDetailClose = () => {
clearDetailTimer();
detailTimer.current = window.setTimeout(() => setDetailId(null), 100);
};
const showDetail = (option: SelectorOption, anchor: HTMLElement) => {
clearDetailTimer();
if (option.disabled || option.details == null || touchInteraction.current) {
setDetailId(null);
return;
}
if (detailId === option.id) return;
setDetailId(null);
detailTimer.current = window.setTimeout(() => {
detailAnchor.current = anchor;
setDetailId(option.id);
}, 1500);
};
useEffect(() => {
if (!open || rows.length === 0) window.clearTimeout(detailTimer.current);
return () => window.clearTimeout(detailTimer.current);
}, [open, rows]);
useLayoutEffect(() => {
if (!detail) return;
const place = () => {
const rect = detailAnchor.current?.getBoundingClientRect();
const card = detailRef.current;
if (!rect || !card) return;
const rightSpace = window.innerWidth - rect.right - 20;
const leftSpace = rect.left - 20;
const left = rightSpace >= card.offsetWidth || rightSpace >= leftSpace
? rect.right + 8 : rect.left - card.offsetWidth - 8;
setDetailPosition({
left: Math.max(12, Math.min(left, window.innerWidth - card.offsetWidth - 12)),
top: Math.max(12, Math.min(rect.top, window.innerHeight - card.offsetHeight - 12)),
});
};
place();
const observer = new ResizeObserver(place);
if (detailRef.current) observer.observe(detailRef.current);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [detail]);
useOnOpen(open, () => {
setQuery("");
setDetailId(null);
moveTo(selected?.id ?? null);
});
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
});
return () => cancelAnimationFrame(frame);
}, [open, activeIndex]);
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) {
hideDetail();
setQuery("");
}
};
const close = () => {
changeOpen(false);
triggerRef.current?.focus();
};
const select = (option: SelectorOption) => {
if (option.disabled) return;
if (props.multiple) {
const liveValue = props.value ?? latestValue.current;
const liveIds = Array.isArray(liveValue) ? liveValue : [];
const enabledIds = liveIds.filter((id) => options.some((item) => item.id === id && !item.disabled));
const next = enabledIds.includes(option.id)
? enabledIds.filter((id) => id !== option.id)
: [...enabledIds, option.id];
if (props.value === undefined) {
latestValue.current = next;
setInternalValue(next);
}
props.onValueChange?.(next);
hideDetail();
} else {
if (props.value === undefined) setInternalValue(option.id);
props.onValueChange?.(option.id);
close();
}
};
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.nativeEvent.isComposing) return;
touchInteraction.current = false;
if (event.key !== "Tab") hideDetail();
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.target !== inputRef.current &&
!(event.target instanceof HTMLElement && event.target.getAttribute("role") === "option")) {
return;
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
for (let step = 0; step < 12; step++) moveActive(event.key === "PageDown" ? 1 : -1);
} else if (event.key === "Enter" && active) {
event.preventDefault();
select(active);
}
};
return (
<MorphPopover open={open} onOpenChange={changeOpen} className={cn("max-w-full", triggerVariant === "pill" ? "w-auto" : "w-64", className)}>
<div className={cn("w-full border border-border bg-background shadow-sm", triggerVariant === "pill" ? "rounded-3xl" : "rounded-xl")}>
<MorphPopoverTrigger>
<button
type="button"
ref={triggerRef}
className={cn("flex min-h-11 w-full items-center gap-2 rounded-[inherit] px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring", classNames?.trigger)}
>
{selected?.icon != null ? <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted", classNames?.icon)}>{selected.icon}</span> : null}
<span className="min-w-0 flex-1">
<span className={cn("block truncate font-medium", !selected && "text-muted-foreground")}>
{props.multiple ? (selectedOptions.length ? `${selectedOptions.length} selected` : placeholder) : selected?.name ?? placeholder}
</span>
{triggerVariant === "workspace" && selected?.description ? <span className="block truncate text-xs text-muted-foreground">{selected.description}</span> : null}
</span>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
>
<ChevronDown className="size-4" />
</motion.span>
</button>
</MorphPopoverTrigger>
{props.multiple && selectedOptions.length ? (
<div className="flex flex-wrap gap-1.5 px-2.5 pb-2.5">
{selectedOptions.map((option) => (
<span key={option.id} className="inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pr-0.5 pl-2 text-xs">
<span className="truncate">{option.name}</span>
<button type="button" aria-label={`Remove ${option.name}`} onClick={() => { select(option); triggerRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-3" /></button>
</span>
))}
</div>
) : null}
</div>
<MorphPopoverContent align="start" sideOffset={6} radius={16} onOpenAutoFocus={() => inputRef.current?.focus()} className={cn("flex w-[22rem] max-w-[calc(100vw-24px)] flex-col bg-background text-sm text-foreground", classNames?.content)}>
<div className="shrink-0 p-2 pb-1">
<div className={cn("flex h-10 items-center gap-2 rounded-lg bg-muted/60 px-3", classNames?.search)}>
<Search aria-hidden className="size-4 shrink-0 text-muted-foreground" />
<input ref={inputRef} onKeyDown={onKeyDown} value={query} onChange={(event) => { hideDetail(); setQuery(event.target.value); }} role="combobox" aria-label={searchPlaceholder} aria-expanded="true" aria-autocomplete="list" aria-controls={`${uid}-options`} aria-activedescendant={active ? `${uid}-option-${activeIndex}` : undefined} placeholder={searchPlaceholder} className="h-full min-w-0 flex-1 bg-transparent text-[inherit] outline-none placeholder:text-muted-foreground" />
{query ? <button type="button" aria-label="Clear search" onClick={() => { hideDetail(); setQuery(""); inputRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-4" /></button> : <kbd className="text-[10px] text-muted-foreground">ESC</kbd>}
</div>
</div>
<div ref={listRef} id={`${uid}-options`} role="listbox" aria-label={label} aria-multiselectable={props.multiple || undefined} onScroll={hideDetail} className={cn("max-h-[min(360px,calc(100dvh-176px))] min-h-0 overflow-y-auto overscroll-contain p-2", SCROLLBAR_CLASS, classNames?.list)}>
{groups.length ? groups.map((group) => (
<fieldset key={group.label} className="mb-2 min-w-0 border-0 p-0 last:mb-0">
{group.label ? <legend className="px-2 py-2 text-[11px] font-medium text-muted-foreground">{group.label}</legend> : null}
<div className={cn(layout === "grid" ? "grid grid-cols-2 gap-2" : "space-y-0.5")}>
{group.items.map((option) => {
const index = rows.indexOf(option);
const isActive = active?.id === option.id;
const isSelected = !option.disabled && selectedIds.has(option.id);
return (
<button key={option.id} id={index >= 0 ? `${uid}-option-${index}` : undefined} type="button" role="option" aria-selected={isSelected} aria-disabled={option.disabled || undefined} disabled={option.disabled} data-index={index >= 0 ? index : undefined} onKeyDown={onKeyDown} tabIndex={-1} aria-describedby={detail?.id === option.id ? `${uid}-details` : undefined}
onPointerEnter={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current || option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }}
onPointerDown={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current) hideDetail(); }} onPointerLeave={scheduleDetailClose}
onFocus={(event) => { if (option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }} onBlur={scheduleDetailClose} onClick={() => select(option)}
className={cn("relative flex min-h-12 w-full gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40", layout === "grid" ? "flex-col items-stretch border border-border" : "items-center", layout === "grid" && isSelected && "border-foreground", isActive && "bg-muted/70", classNames?.option)}>
{layout === "grid" ? <div className="flex aspect-[16/10] items-center justify-center overflow-hidden rounded-lg bg-muted/60 text-muted-foreground">{option.preview ?? option.icon ?? <span className="text-2xl font-medium">{option.name.slice(0, 1)}</span>}</div> : <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground", classNames?.icon)}>{option.icon ?? <span className="text-xs font-medium">{option.name.slice(0, 1)}</span>}</span>}
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{option.name}</span>{option.description ? <span className="block truncate text-xs text-muted-foreground">{option.description}</span> : null}</span>
{option.metadata ? <span className="shrink-0 text-[10px] text-muted-foreground">{option.metadata}</span> : null}
<span aria-hidden className={cn("flex size-4 shrink-0 items-center justify-center", props.multiple && "rounded border border-border", props.multiple && isSelected && "border-foreground bg-foreground text-background")}>{isSelected ? <Check className="size-3.5" /> : null}</span>
</span>
</button>
);
})}
</div>
</fieldset>
)) : <p className="px-3 py-8 text-center text-sm text-muted-foreground">No options found.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex flex-wrap gap-x-3 gap-y-1">{props.multiple ? <span>{selectedOptions.length} selected</span> : <><span><kbd>↑↓</kbd> Navigate</span><span><kbd>↵</kbd> Select</span></>}</span>
<div className="flex items-center gap-2">
{action ? <button type="button" onClick={() => { close(); action.onSelect(); }} className="rounded-md px-2 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring">{action.label}</button> : null}
{props.multiple ? <button type="button" onClick={close} className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background focus-visible:outline-2 focus-visible:outline-ring">Done</button> : null}
</div>
</div>
</MorphPopoverContent>
{detail ? createPortal(
<aside ref={detailRef} id={`${uid}-details`} aria-label="Option details" onKeyDown={onKeyDown} onPointerEnter={clearDetailTimer} onPointerLeave={scheduleDetailClose} onPointerDown={(event) => event.stopPropagation()} onFocus={clearDetailTimer} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) scheduleDetailClose(); }} className={cn("fixed z-[10000] max-h-[min(420px,70dvh)] w-[21rem] max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-background p-4 text-sm text-foreground shadow-xl", SCROLLBAR_CLASS, classNames?.details)} style={{ ...detailPosition, backgroundColor: "var(--background)" }}>{detail.details}</aside>, document.body,
) : null}
</MorphPopover>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
"use client";
import { useState } from "react";
/**
* Runs `start` during the render in which `open` becomes true.
*
* Opening a list starts a fresh session — an empty query, the highlight back at
* the top — and that is a resolution, not a side effect: a passive effect runs
* after the commit, so the new session would carry the last one's state for a
* window in which a key press can land.
*
* `start` may only set state belonging to the calling component. Anything that
* reaches outside it — a consumer's callback, a DOM write, moving focus — is a
* side effect, and React rejects a callback that sets another component's state
* during this one's render. Put those in an effect keyed to `open`.
*/
export function useOnOpen(open: boolean, start: () => void) {
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) start();
}
}
"use client";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
/**
* Where the keyboard or the pointer last moved to: the row's id, stamped with
* the query it was placed under.
*/
type RowCursor = { id: string; query: string };
/** The cursor's row, or -1 once the query has moved on or the row has left. */
function indexOfCursor(
rows: readonly { id: string }[],
query: string,
cursor: RowCursor | null,
) {
if (cursor === null || cursor.query !== query) return -1;
return rows.findIndex((row) => row.id === cursor.id);
}
/**
* The highlighted row of a list whose rows can change under it.
*
* Which row is highlighted is resolved during render, never in a passive
* effect: a passive effect runs after the commit, so a list that had just
* changed would carry an `aria-activedescendant` naming a row that has left it,
* and a key pressed in that window would commit the wrong row or nothing at
* all.
*
* The cursor holds the row's id, not its position. A position alone cannot tell
* a list that shrank from one that swapped its rows for a different set of the
* same length, and the second case is the one that silently hands Enter to a
* row the user never chose. A cursor whose row has left the list returns the
* highlight to the first row rather than to the nearest surviving one: the row
* the user aimed at is gone, and the first row is where a new query already
* puts the highlight.
*
* Pass the query the list is filtered by. The cursor is stamped with it and
* dropped when it changes, so a caller cannot forget to clear it — including a
* caller whose query arrives as a prop and so never runs its own handler. Rows
* that come back under a query that has moved on cannot revive it either.
* `moveTo(null)` is for deliberate resets, such as reopening the list.
*
* Both callbacks keep one identity for the life of the component, and read the
* rows and the query through a ref to do it. A caller will put them in an
* effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`
* rebuilt on every keystroke would re-run that effect on every keystroke.
*/
export function useRowCursor(rows: readonly { id: string }[], query: string) {
const [cursor, setCursor] = useState<RowCursor | null>(null);
// Written after commit, not during render: a render React discards or has not
// finished still runs the component body, and an event handler that read this
// in that window would stamp the cursor with a query the committed tree does
// not have.
const latest = useRef({ rows, query });
useLayoutEffect(() => {
latest.current = { rows, query };
});
const cursorRow = indexOfCursor(rows, query, cursor);
// Cleared rather than ignored: React re-runs this render with the cursor
// already gone, so rows that come back cannot revive a highlight the user has
// stopped aiming at.
if (cursor !== null && cursorRow < 0) setCursor(null);
const moveTo = useCallback(
(id: string | null) =>
setCursor(id === null ? null : { id, query: latest.current.query }),
[],
);
const moveActive = useCallback((direction: 1 | -1) => {
const { rows: live, query: liveQuery } = latest.current;
const last = live.length - 1;
if (last < 0) return;
// Steps from the row the cursor is really on, inside the update, so that
// two keys landing in one batch move two rows rather than one.
setCursor((current) => {
const at = Math.max(indexOfCursor(live, liveQuery, current), 0);
const next = Math.min(Math.max(at + direction, 0), last);
return { id: live[next].id, query: liveQuery };
});
}, []);
return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// asharca.github.io/ui/components/blocks/selector
import { Check, ChevronDown, Search, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { SPRING_PRESS } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
export type SelectorOption = {
id: string;
name: string;
description?: string;
group?: string;
metadata?: string;
keywords?: string[];
icon?: ReactNode;
preview?: ReactNode;
details?: ReactNode;
disabled?: boolean;
};
/** Optional Tailwind overrides, merged after each slot's defaults. */
export type SelectorClassNames = {
trigger?: string;
content?: string;
list?: string;
option?: string;
icon?: string;
search?: string;
details?: string;
};
type SharedSelectorProps = {
options: SelectorOption[];
defaultOpen?: boolean;
placeholder?: string;
searchPlaceholder?: string;
label?: string;
triggerVariant?: "pill" | "field" | "workspace";
layout?: "list" | "grid";
action?: { label: string; onSelect: () => void };
className?: string;
/** Style individual parts independently; className styles the outer wrapper. */
classNames?: SelectorClassNames;
};
export type SelectorProps = SharedSelectorProps & (
| {
multiple?: false;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
| {
multiple: true;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
}
);
const SCROLLBAR_CLASS =
"[scrollbar-width:thin] [scrollbar-gutter:stable] [scrollbar-color:color-mix(in_oklab,var(--foreground)_18%,transparent)_transparent] hover:[scrollbar-color:color-mix(in_oklab,var(--foreground)_30%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb:hover]:bg-foreground/30";
export function Selector(props: SelectorProps) {
const {
options,
defaultOpen = false,
placeholder = "Select an option",
searchPlaceholder = "Search options",
label = "Options",
triggerVariant = "field",
layout = "list",
action,
className,
classNames,
} = props;
const uid = useId();
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState<string | string[]>(() =>
props.defaultValue ?? (props.multiple ? [] : options.find((option) => !option.disabled)?.id ?? ""),
);
const currentValue = props.value ?? internalValue;
const latestValue = useRef(currentValue);
useLayoutEffect(() => {
latestValue.current = currentValue;
});
const selectedIds = new Set(Array.isArray(currentValue) ? currentValue : [currentValue]);
const selectedOptions = options.filter((option) => !option.disabled && selectedIds.has(option.id));
const selected = selectedOptions[0];
const [open, setOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const detailRef = useRef<HTMLElement>(null);
const detailAnchor = useRef<HTMLElement | null>(null);
const detailTimer = useRef<number | undefined>(undefined);
const touchInteraction = useRef(false);
const [detailId, setDetailId] = useState<string | null>(null);
const [detailPosition, setDetailPosition] = useState({ top: 12, left: 12 });
const groups = useMemo(() => {
const result = new Map<string, SelectorOption[]>();
const search = query.trim().toLowerCase();
for (const option of options) {
if (![option.name, option.description, option.group, option.metadata, ...(option.keywords ?? [])]
.filter(Boolean).join(" ").toLowerCase().includes(search)) continue;
const group = option.group ?? "";
const items = result.get(group) ?? [];
items.push(option);
result.set(group, items);
}
return Array.from(result, ([label, items]) => ({ label, items }));
}, [options, query]);
const rows = useMemo(() => groups.flatMap((group) => group.items).filter((option) => !option.disabled), [groups]);
const { activeIndex, moveTo, moveActive } = useRowCursor(rows, query);
const active = rows[activeIndex];
const detail = open ? rows.find((option) => option.id === detailId && option.details != null) : undefined;
const clearDetailTimer = () => {
window.clearTimeout(detailTimer.current);
detailTimer.current = undefined;
};
const hideDetail = () => {
clearDetailTimer();
setDetailId(null);
};
const scheduleDetailClose = () => {
clearDetailTimer();
detailTimer.current = window.setTimeout(() => setDetailId(null), 100);
};
const showDetail = (option: SelectorOption, anchor: HTMLElement) => {
clearDetailTimer();
if (option.disabled || option.details == null || touchInteraction.current) {
setDetailId(null);
return;
}
if (detailId === option.id) return;
setDetailId(null);
detailTimer.current = window.setTimeout(() => {
detailAnchor.current = anchor;
setDetailId(option.id);
}, 1500);
};
useEffect(() => {
if (!open || rows.length === 0) window.clearTimeout(detailTimer.current);
return () => window.clearTimeout(detailTimer.current);
}, [open, rows]);
useLayoutEffect(() => {
if (!detail) return;
const place = () => {
const rect = detailAnchor.current?.getBoundingClientRect();
const card = detailRef.current;
if (!rect || !card) return;
const rightSpace = window.innerWidth - rect.right - 20;
const leftSpace = rect.left - 20;
const left = rightSpace >= card.offsetWidth || rightSpace >= leftSpace
? rect.right + 8 : rect.left - card.offsetWidth - 8;
setDetailPosition({
left: Math.max(12, Math.min(left, window.innerWidth - card.offsetWidth - 12)),
top: Math.max(12, Math.min(rect.top, window.innerHeight - card.offsetHeight - 12)),
});
};
place();
const observer = new ResizeObserver(place);
if (detailRef.current) observer.observe(detailRef.current);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [detail]);
useOnOpen(open, () => {
setQuery("");
setDetailId(null);
moveTo(selected?.id ?? null);
});
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
});
return () => cancelAnimationFrame(frame);
}, [open, activeIndex]);
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) {
hideDetail();
setQuery("");
}
};
const close = () => {
changeOpen(false);
triggerRef.current?.focus();
};
const select = (option: SelectorOption) => {
if (option.disabled) return;
if (props.multiple) {
const liveValue = props.value ?? latestValue.current;
const liveIds = Array.isArray(liveValue) ? liveValue : [];
const enabledIds = liveIds.filter((id) => options.some((item) => item.id === id && !item.disabled));
const next = enabledIds.includes(option.id)
? enabledIds.filter((id) => id !== option.id)
: [...enabledIds, option.id];
if (props.value === undefined) {
latestValue.current = next;
setInternalValue(next);
}
props.onValueChange?.(next);
hideDetail();
} else {
if (props.value === undefined) setInternalValue(option.id);
props.onValueChange?.(option.id);
close();
}
};
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.nativeEvent.isComposing) return;
touchInteraction.current = false;
if (event.key !== "Tab") hideDetail();
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.target !== inputRef.current &&
!(event.target instanceof HTMLElement && event.target.getAttribute("role") === "option")) {
return;
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
for (let step = 0; step < 12; step++) moveActive(event.key === "PageDown" ? 1 : -1);
} else if (event.key === "Enter" && active) {
event.preventDefault();
select(active);
}
};
return (
<MorphPopover open={open} onOpenChange={changeOpen} className={cn("max-w-full", triggerVariant === "pill" ? "w-auto" : "w-64", className)}>
<div className={cn("w-full border border-border bg-background shadow-sm", triggerVariant === "pill" ? "rounded-3xl" : "rounded-xl")}>
<MorphPopoverTrigger>
<button
type="button"
ref={triggerRef}
className={cn("flex min-h-11 w-full items-center gap-2 rounded-[inherit] px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring", classNames?.trigger)}
>
{selected?.icon != null ? <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted", classNames?.icon)}>{selected.icon}</span> : null}
<span className="min-w-0 flex-1">
<span className={cn("block truncate font-medium", !selected && "text-muted-foreground")}>
{props.multiple ? (selectedOptions.length ? `${selectedOptions.length} selected` : placeholder) : selected?.name ?? placeholder}
</span>
{triggerVariant === "workspace" && selected?.description ? <span className="block truncate text-xs text-muted-foreground">{selected.description}</span> : null}
</span>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
>
<ChevronDown className="size-4" />
</motion.span>
</button>
</MorphPopoverTrigger>
{props.multiple && selectedOptions.length ? (
<div className="flex flex-wrap gap-1.5 px-2.5 pb-2.5">
{selectedOptions.map((option) => (
<span key={option.id} className="inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pr-0.5 pl-2 text-xs">
<span className="truncate">{option.name}</span>
<button type="button" aria-label={`Remove ${option.name}`} onClick={() => { select(option); triggerRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-3" /></button>
</span>
))}
</div>
) : null}
</div>
<MorphPopoverContent align="start" sideOffset={6} radius={16} onOpenAutoFocus={() => inputRef.current?.focus()} className={cn("flex w-[22rem] max-w-[calc(100vw-24px)] flex-col bg-background text-sm text-foreground", classNames?.content)}>
<div className="shrink-0 p-2 pb-1">
<div className={cn("flex h-10 items-center gap-2 rounded-lg bg-muted/60 px-3", classNames?.search)}>
<Search aria-hidden className="size-4 shrink-0 text-muted-foreground" />
<input ref={inputRef} onKeyDown={onKeyDown} value={query} onChange={(event) => { hideDetail(); setQuery(event.target.value); }} role="combobox" aria-label={searchPlaceholder} aria-expanded="true" aria-autocomplete="list" aria-controls={`${uid}-options`} aria-activedescendant={active ? `${uid}-option-${activeIndex}` : undefined} placeholder={searchPlaceholder} className="h-full min-w-0 flex-1 bg-transparent text-[inherit] outline-none placeholder:text-muted-foreground" />
{query ? <button type="button" aria-label="Clear search" onClick={() => { hideDetail(); setQuery(""); inputRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-4" /></button> : <kbd className="text-[10px] text-muted-foreground">ESC</kbd>}
</div>
</div>
<div ref={listRef} id={`${uid}-options`} role="listbox" aria-label={label} aria-multiselectable={props.multiple || undefined} onScroll={hideDetail} className={cn("max-h-[min(360px,calc(100dvh-176px))] min-h-0 overflow-y-auto overscroll-contain p-2", SCROLLBAR_CLASS, classNames?.list)}>
{groups.length ? groups.map((group) => (
<fieldset key={group.label} className="mb-2 min-w-0 border-0 p-0 last:mb-0">
{group.label ? <legend className="px-2 py-2 text-[11px] font-medium text-muted-foreground">{group.label}</legend> : null}
<div className={cn(layout === "grid" ? "grid grid-cols-2 gap-2" : "space-y-0.5")}>
{group.items.map((option) => {
const index = rows.indexOf(option);
const isActive = active?.id === option.id;
const isSelected = !option.disabled && selectedIds.has(option.id);
return (
<button key={option.id} id={index >= 0 ? `${uid}-option-${index}` : undefined} type="button" role="option" aria-selected={isSelected} aria-disabled={option.disabled || undefined} disabled={option.disabled} data-index={index >= 0 ? index : undefined} onKeyDown={onKeyDown} tabIndex={-1} aria-describedby={detail?.id === option.id ? `${uid}-details` : undefined}
onPointerEnter={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current || option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }}
onPointerDown={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current) hideDetail(); }} onPointerLeave={scheduleDetailClose}
onFocus={(event) => { if (option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }} onBlur={scheduleDetailClose} onClick={() => select(option)}
className={cn("relative flex min-h-12 w-full gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40", layout === "grid" ? "flex-col items-stretch border border-border" : "items-center", layout === "grid" && isSelected && "border-foreground", isActive && "bg-muted/70", classNames?.option)}>
{layout === "grid" ? <div className="flex aspect-[16/10] items-center justify-center overflow-hidden rounded-lg bg-muted/60 text-muted-foreground">{option.preview ?? option.icon ?? <span className="text-2xl font-medium">{option.name.slice(0, 1)}</span>}</div> : <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground", classNames?.icon)}>{option.icon ?? <span className="text-xs font-medium">{option.name.slice(0, 1)}</span>}</span>}
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{option.name}</span>{option.description ? <span className="block truncate text-xs text-muted-foreground">{option.description}</span> : null}</span>
{option.metadata ? <span className="shrink-0 text-[10px] text-muted-foreground">{option.metadata}</span> : null}
<span aria-hidden className={cn("flex size-4 shrink-0 items-center justify-center", props.multiple && "rounded border border-border", props.multiple && isSelected && "border-foreground bg-foreground text-background")}>{isSelected ? <Check className="size-3.5" /> : null}</span>
</span>
</button>
);
})}
</div>
</fieldset>
)) : <p className="px-3 py-8 text-center text-sm text-muted-foreground">No options found.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex flex-wrap gap-x-3 gap-y-1">{props.multiple ? <span>{selectedOptions.length} selected</span> : <><span><kbd>↑↓</kbd> Navigate</span><span><kbd>↵</kbd> Select</span></>}</span>
<div className="flex items-center gap-2">
{action ? <button type="button" onClick={() => { close(); action.onSelect(); }} className="rounded-md px-2 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring">{action.label}</button> : null}
{props.multiple ? <button type="button" onClick={close} className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background focus-visible:outline-2 focus-visible:outline-ring">Done</button> : null}
</div>
</div>
</MorphPopoverContent>
{detail ? createPortal(
<aside ref={detailRef} id={`${uid}-details`} aria-label="Option details" onKeyDown={onKeyDown} onPointerEnter={clearDetailTimer} onPointerLeave={scheduleDetailClose} onPointerDown={(event) => event.stopPropagation()} onFocus={clearDetailTimer} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) scheduleDetailClose(); }} className={cn("fixed z-[10000] max-h-[min(420px,70dvh)] w-[21rem] max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-background p-4 text-sm text-foreground shadow-xl", SCROLLBAR_CLASS, classNames?.details)} style={{ ...detailPosition, backgroundColor: "var(--background)" }}>{detail.details}</aside>, document.body,
) : null}
</MorphPopover>
);
}
"use client";
import {
AnimatePresence,
motion,
animate,
useMotionValue,
usePresence,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
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 React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child?.props?.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child?.props as { ref?: Ref<HTMLElement> } | undefined)
?.ref;
// Register once per actual ref change, not once per open-state render.
const mergedRef = useMemo(
() => mergeRefs(childRef, ctx.registerTrigger),
[childRef, ctx.registerTrigger],
);
if (!isValidElement(children)) return children;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergedRef,
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipAt(side: Side, align: Align, radius: number, inset: number) {
const top = side === "bottom" ? "0%" : `${inset}%`;
const bottom = side === "bottom" ? `${inset}%` : "0%";
const right = align === "end" ? "0%" : `${inset}%`;
const left = align === "end" ? `${inset}%` : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
/** Draw the surface shadow. Default true. */
shadow?: boolean;
/** Runs after the portalled surface is positioned and visible. */
onOpenAutoFocus?: (content: HTMLDivElement) => void;
/** Override the panel landmark. Menus pass "menu" instead of the dialog default. */
role?: "dialog" | "menu";
className?: string;
}
export function MorphPopoverContent(props: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const [portalReady, setPortalReady] = useState(false);
useEffect(() => setPortalReady(true), []);
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open && <MorphPopoverSurface {...props} />}
</AnimatePresence>,
document.body,
);
}
// Measurement belongs to the mounted portal session: reopening must not start
// an entrance at the previous session's coordinates before measuring this one.
function MorphPopoverSurface({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
shadow = true,
onOpenAutoFocus,
role = "dialog",
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [isPresent, safeToRemove] = usePresence();
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
isPresent,
);
const preferredLeft = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
// Larger compositions, such as calendars, use the roomier side if needed.
const gutter = 8;
const below = layout
? Math.max(0, window.innerHeight - layout.trigger.top - layout.trigger.height - sideOffset - gutter)
: 0;
const above = layout ? Math.max(0, layout.trigger.top - sideOffset - gutter) : 0;
const requestedSpace = side === "bottom" ? below : above;
const otherSpace = side === "bottom" ? above : below;
const resolvedSide = layout && layout.content.height > requestedSpace && otherSpace > requestedSpace
? side === "bottom" ? "top" : "bottom"
: side;
const availableHeight = resolvedSide === "bottom" ? below : above;
const left = layout
? Math.max(gutter, Math.min(preferredLeft, window.innerWidth - layout.content.width - gutter))
: 0;
const top = layout
? resolvedSide === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: Math.max(gutter, layout.trigger.top - layout.content.height - sideOffset)
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { scale: 0.96, transition: SPRING_PANEL },
show: { scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipAt(resolvedSide, align, radius, 92),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipAt(resolvedSide, align, radius, 0),
transition: MORPH_CLIP_TRANSITION,
},
};
// Animate the value directly so opacity stays in the inline style throughout
// the entrance. A native opacity animation can expose the initial inline 0
// for a frame when it finishes, before Motion writes the final value.
const opacity = useMotionValue(0);
const ready = layout !== null;
const focusedOnOpen = useRef(false);
useEffect(() => {
if (!ready || !isPresent || focusedOnOpen.current || !ctx.contentRef.current) return;
focusedOnOpen.current = true;
onOpenAutoFocus?.(ctx.contentRef.current);
}, [ready, isPresent, ctx.contentRef, onOpenAutoFocus]);
useEffect(() => {
if (!ready) {
if (!isPresent) safeToRemove?.();
return;
}
const animation = animate(opacity, isPresent ? 1 : 0, {
...(reduce ? { duration: 0.12 } : SPRING_PANEL),
onComplete: () => {
if (!isPresent) safeToRemove?.();
},
});
return () => animation.stop();
}, [opacity, ready, isPresent, reduce, safeToRemove]);
return (
<motion.div
data-morph-popover-portal=""
inert={!isPresent}
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial="hidden"
animate={layout ? "show" : "hidden"}
exit="hidden"
style={{
left,
top,
opacity,
pointerEvents: isPresent ? "auto" : "none",
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(resolvedSide, align),
}}
className={cn("fixed z-[9999]", shadow && "[filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]")}
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role={role}
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius, maxHeight: layout ? availableHeight : undefined, overflowY: "auto" }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
);
}
"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;
}
People · Single select
selector.tsxChoose one assignee with a string value and onValueChange. Start with value="" or defaultValue="" to show a placeholder before selection. Search names and email descriptions; selecting a person closes the panel and restores focus to the trigger. Omit multiple for single selection; the multiple example below uses the same options with an array of IDs.
Choose an assignee
Search by name or email and choose one person responsible for the task.
No assignee yet.
"use client";
import { Bot, Brain, Building2, Circle, Sparkles } from "lucide-react";
import { useState } from "react";
import { Selector } from "@/components/motion/selector";
import type { SelectorOption } from "@/components/motion/selector";
const MODELS: SelectorOption[] = [
{
id: "claude-sonnet",
name: "Claude Sonnet",
group: "Anthropic",
description: "Balanced reasoning and everyday coding",
metadata: "200K",
keywords: ["reasoning", "vision", "tools"],
icon: <Brain className="size-4"/>,
details: (<div className="space-y-2">
<p className="font-medium">Claude Sonnet</p>
<p className="text-sm text-muted-foreground">
A balanced choice for writing, analysis, and everyday coding. Supports
images and tools with a 200K-token context window.
</p>
</div>),
},
{
id: "claude-opus",
name: "Claude Opus",
group: "Anthropic",
description: "Deep reasoning for complex work",
metadata: "200K",
keywords: ["reasoning", "analysis"],
icon: <Brain className="size-4"/>,
},
{
id: "gpt-4o",
name: "GPT-4o",
group: "OpenAI",
description: "Fast, multimodal conversations",
metadata: "128K",
keywords: ["vision", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gpt-4.1",
name: "GPT-4.1",
group: "OpenAI",
description: "Precise instructions and a larger context",
metadata: "1M",
keywords: ["coding", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gemini-pro",
name: "Gemini Pro",
group: "Google",
description: "Reason across documents, images, and code",
metadata: "1M",
keywords: ["vision", "reasoning"],
icon: <Bot className="size-4"/>,
},
];
export function SelectorPreview() {
const [model, setModel] = useState("claude-sonnet");
return (<div className="w-full max-w-md space-y-4">
<p className="font-medium text-sm">Choose your model</p>
<Selector options={MODELS} value={model} onValueChange={setModel} triggerVariant="pill" label="Models" searchPlaceholder="Search models…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Using {MODELS.find((option) => option.id === model)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Search by model, provider, or capability. Hover or focus Claude Sonnet
for details. Customize the trigger and panel with classNames.
</p>
</div>);
}
const PEOPLE: SelectorOption[] = [
{ id: "ava", name: "Ava Thompson", description: "ava@studio.co", metadata: "Design", group: "Your team" },
{ id: "noah", name: "Noah Williams", description: "noah@studio.co", metadata: "Engineering", group: "Your team" },
{ id: "mia", name: "Mia Chen", description: "mia@studio.co", metadata: "Product", group: "Your team" },
{ id: "leo", name: "Leo Martin", description: "leo@partner.co", metadata: "Guest", group: "Collaborators" },
].map((person) => ({
...person,
icon: (<span className="flex size-7 items-center justify-center rounded-full border bg-muted text-[10px] font-medium">
{person.name.split(" ").map((part) => part[0]).join("")}
</span>),
}));
export function SelectorPeopleSinglePreview() {
const [person, setPerson] = useState("");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Choose an assignee</p>
<p className="text-xs text-muted-foreground">
Search by name or email and choose one person responsible for the task.
</p>
</div>
<Selector options={PEOPLE} value={person} onValueChange={setPerson} placeholder="Assign to someone" searchPlaceholder="Search people…" label="Assignee"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{person ? `Assigned to ${PEOPLE.find((option) => option.id === person)?.name}.` : "No assignee yet."}
</p>
</div>);
}
export function SelectorPeoplePreview() {
const [people, setPeople] = useState<string[]>(["ava", "mia"]);
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Share with your team</p>
<p className="text-xs text-muted-foreground">
Search names or emails, then select everyone who needs access.
</p>
</div>
<Selector multiple options={PEOPLE} value={people} onValueChange={setPeople} triggerVariant="field" placeholder="Add people" searchPlaceholder="Search people…" label="People"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{people.length === 0
? "Only you have access."
: `Shared with ${people.length} ${people.length === 1 ? "person" : "people"}.`}
</p>
<p className="text-xs text-muted-foreground">
Remove a chip to revoke access, or choose Done when finished.
</p>
</div>);
}
const WORKSPACES: SelectorOption[] = [
{
id: "studio",
name: "Studio",
description: "Pro plan · 12 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "engineering",
name: "Engineering",
description: "Pro plan · 8 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "personal",
name: "Personal",
description: "Free plan · Just you",
group: "Personal workspaces",
icon: <Circle className="size-4"/>,
},
];
export function SelectorWorkspacePreview() {
const [options, setOptions] = useState(WORKSPACES);
const [workspace, setWorkspace] = useState("studio");
function createWorkspace() {
const number = options.length - WORKSPACES.length + 1;
const id = `new-workspace-${number}`;
setOptions([
...options,
{
id,
name: `New workspace ${number}`,
description: "Free plan · Just you",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
]);
setWorkspace(id);
}
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Your work, in one place</p>
<p className="text-xs text-muted-foreground">
Switch between team and personal workspaces.
</p>
</div>
<Selector options={options} value={workspace} onValueChange={setWorkspace} triggerVariant="workspace" searchPlaceholder="Find a workspace…" label="Workspaces" action={{ label: "Create workspace", onSelect: createWorkspace }}/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Current workspace: {options.find((option) => option.id === workspace)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Create workspace adds a new workspace and switches to it immediately.
New workspaces live in this demo until the page is refreshed.
</p>
</div>);
}
function TemplateThumbnail({ variant }: {
variant: "dashboard" | "editorial" | "board" | "portfolio";
}) {
return (<div aria-hidden="true" className="flex aspect-[16/10] w-full overflow-hidden rounded-lg border bg-background p-2 text-foreground">
{variant === "dashboard" ? (<>
<div className="mr-2 w-1/5 space-y-2 border-r pr-2">
<div className="h-2 w-3 rounded-sm bg-foreground/60"/>
{[0, 1, 2, 3].map((item) => <div key={item} className="h-1 rounded bg-foreground/15"/>)}
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="h-1.5 w-1/2 rounded bg-foreground/40"/>
<div className="grid grid-cols-3 gap-1">
{[0, 1, 2].map((item) => <div key={item} className="h-5 rounded border bg-foreground/5"/>)}
</div>
<div className="flex flex-1 items-end gap-1 rounded border p-1">
{[30, 55, 40, 75, 60, 90, 70].map((height) => <div key={height} className="flex-1 rounded-t-sm bg-foreground/25" style={{ height: `${height}%` }}/>)}
</div>
</div>
</>) : variant === "editorial" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1 w-full border-b"/>
<div className="mx-auto h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="mx-auto h-1 w-1/3 rounded bg-foreground/20"/>
<div className="flex flex-1 gap-2">
<div className="w-1/2 rounded bg-foreground/10"/>
<div className="flex-1 space-y-1.5 pt-1">
{[0, 1, 2, 3, 4].map((item) => <div key={item} className="h-1 rounded bg-foreground/15 last:w-2/3"/>)}
</div>
</div>
</div>) : variant === "board" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1.5 w-1/3 rounded bg-foreground/50"/>
<div className="grid flex-1 grid-cols-3 gap-1.5">
{[2, 3, 1].map((count) => (<div key={count} className="space-y-1 rounded bg-foreground/5 p-1">
<div className="mb-2 h-1 w-2/3 rounded bg-foreground/25"/>
{["first", "second", "third"].slice(0, count).map((card) => <div key={card} className="h-4 rounded-sm border bg-background"/>)}
</div>))}
</div>
</div>) : (<div className="flex w-full flex-col gap-2">
<div className="flex justify-between"><div className="h-1.5 w-6 rounded bg-foreground/50"/><div className="h-1 w-8 rounded bg-foreground/15"/></div>
<div className="mt-1 h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="h-1 w-1/2 rounded bg-foreground/20"/>
<div className="grid flex-1 grid-cols-2 gap-2"><div className="rounded bg-foreground/10"/><div className="rounded bg-foreground/20"/></div>
</div>)}
</div>);
}
const RESOURCES: SelectorOption[] = [
{ id: "dashboard", name: "Dashboard", description: "Metrics at a glance", metadata: "12 blocks", preview: <TemplateThumbnail variant="dashboard"/>, keywords: ["analytics", "charts"] },
{ id: "editorial", name: "Editorial", description: "Stories worth reading", metadata: "8 blocks", preview: <TemplateThumbnail variant="editorial"/>, keywords: ["blog", "writing"] },
{ id: "board", name: "Project board", description: "A little more organized", metadata: "6 blocks", preview: <TemplateThumbnail variant="board"/>, keywords: ["kanban", "tasks"] },
{ id: "portfolio", name: "Portfolio", description: "Make your work the focus", metadata: "10 blocks", preview: <TemplateThumbnail variant="portfolio"/>, keywords: ["gallery", "personal"] },
];
export function SelectorResourcesPreview() {
const [resource, setResource] = useState("dashboard");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Start with a template</p>
<p className="text-xs text-muted-foreground">
Browse the previews or search for a layout that fits your next project.
</p>
</div>
<Selector options={RESOURCES} value={resource} onValueChange={setResource} layout="grid" triggerVariant="field" classNames={{ content: "w-[30rem]", list: "max-h-[26rem]" }} label="Templates" searchPlaceholder="Search templates…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Selected template: {RESOURCES.find((option) => option.id === resource)?.name}.
</p>
</div>);
}
"use client";
// asharca.github.io/ui/components/blocks/selector
import { Check, ChevronDown, Search, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { SPRING_PRESS } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
export type SelectorOption = {
id: string;
name: string;
description?: string;
group?: string;
metadata?: string;
keywords?: string[];
icon?: ReactNode;
preview?: ReactNode;
details?: ReactNode;
disabled?: boolean;
};
/** Optional Tailwind overrides, merged after each slot's defaults. */
export type SelectorClassNames = {
trigger?: string;
content?: string;
list?: string;
option?: string;
icon?: string;
search?: string;
details?: string;
};
type SharedSelectorProps = {
options: SelectorOption[];
defaultOpen?: boolean;
placeholder?: string;
searchPlaceholder?: string;
label?: string;
triggerVariant?: "pill" | "field" | "workspace";
layout?: "list" | "grid";
action?: { label: string; onSelect: () => void };
className?: string;
/** Style individual parts independently; className styles the outer wrapper. */
classNames?: SelectorClassNames;
};
export type SelectorProps = SharedSelectorProps & (
| {
multiple?: false;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
| {
multiple: true;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
}
);
const SCROLLBAR_CLASS =
"[scrollbar-width:thin] [scrollbar-gutter:stable] [scrollbar-color:color-mix(in_oklab,var(--foreground)_18%,transparent)_transparent] hover:[scrollbar-color:color-mix(in_oklab,var(--foreground)_30%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb:hover]:bg-foreground/30";
export function Selector(props: SelectorProps) {
const {
options,
defaultOpen = false,
placeholder = "Select an option",
searchPlaceholder = "Search options",
label = "Options",
triggerVariant = "field",
layout = "list",
action,
className,
classNames,
} = props;
const uid = useId();
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState<string | string[]>(() =>
props.defaultValue ?? (props.multiple ? [] : options.find((option) => !option.disabled)?.id ?? ""),
);
const currentValue = props.value ?? internalValue;
const latestValue = useRef(currentValue);
useLayoutEffect(() => {
latestValue.current = currentValue;
});
const selectedIds = new Set(Array.isArray(currentValue) ? currentValue : [currentValue]);
const selectedOptions = options.filter((option) => !option.disabled && selectedIds.has(option.id));
const selected = selectedOptions[0];
const [open, setOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const detailRef = useRef<HTMLElement>(null);
const detailAnchor = useRef<HTMLElement | null>(null);
const detailTimer = useRef<number | undefined>(undefined);
const touchInteraction = useRef(false);
const [detailId, setDetailId] = useState<string | null>(null);
const [detailPosition, setDetailPosition] = useState({ top: 12, left: 12 });
const groups = useMemo(() => {
const result = new Map<string, SelectorOption[]>();
const search = query.trim().toLowerCase();
for (const option of options) {
if (![option.name, option.description, option.group, option.metadata, ...(option.keywords ?? [])]
.filter(Boolean).join(" ").toLowerCase().includes(search)) continue;
const group = option.group ?? "";
const items = result.get(group) ?? [];
items.push(option);
result.set(group, items);
}
return Array.from(result, ([label, items]) => ({ label, items }));
}, [options, query]);
const rows = useMemo(() => groups.flatMap((group) => group.items).filter((option) => !option.disabled), [groups]);
const { activeIndex, moveTo, moveActive } = useRowCursor(rows, query);
const active = rows[activeIndex];
const detail = open ? rows.find((option) => option.id === detailId && option.details != null) : undefined;
const clearDetailTimer = () => {
window.clearTimeout(detailTimer.current);
detailTimer.current = undefined;
};
const hideDetail = () => {
clearDetailTimer();
setDetailId(null);
};
const scheduleDetailClose = () => {
clearDetailTimer();
detailTimer.current = window.setTimeout(() => setDetailId(null), 100);
};
const showDetail = (option: SelectorOption, anchor: HTMLElement) => {
clearDetailTimer();
if (option.disabled || option.details == null || touchInteraction.current) {
setDetailId(null);
return;
}
if (detailId === option.id) return;
setDetailId(null);
detailTimer.current = window.setTimeout(() => {
detailAnchor.current = anchor;
setDetailId(option.id);
}, 1500);
};
useEffect(() => {
if (!open || rows.length === 0) window.clearTimeout(detailTimer.current);
return () => window.clearTimeout(detailTimer.current);
}, [open, rows]);
useLayoutEffect(() => {
if (!detail) return;
const place = () => {
const rect = detailAnchor.current?.getBoundingClientRect();
const card = detailRef.current;
if (!rect || !card) return;
const rightSpace = window.innerWidth - rect.right - 20;
const leftSpace = rect.left - 20;
const left = rightSpace >= card.offsetWidth || rightSpace >= leftSpace
? rect.right + 8 : rect.left - card.offsetWidth - 8;
setDetailPosition({
left: Math.max(12, Math.min(left, window.innerWidth - card.offsetWidth - 12)),
top: Math.max(12, Math.min(rect.top, window.innerHeight - card.offsetHeight - 12)),
});
};
place();
const observer = new ResizeObserver(place);
if (detailRef.current) observer.observe(detailRef.current);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [detail]);
useOnOpen(open, () => {
setQuery("");
setDetailId(null);
moveTo(selected?.id ?? null);
});
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
});
return () => cancelAnimationFrame(frame);
}, [open, activeIndex]);
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) {
hideDetail();
setQuery("");
}
};
const close = () => {
changeOpen(false);
triggerRef.current?.focus();
};
const select = (option: SelectorOption) => {
if (option.disabled) return;
if (props.multiple) {
const liveValue = props.value ?? latestValue.current;
const liveIds = Array.isArray(liveValue) ? liveValue : [];
const enabledIds = liveIds.filter((id) => options.some((item) => item.id === id && !item.disabled));
const next = enabledIds.includes(option.id)
? enabledIds.filter((id) => id !== option.id)
: [...enabledIds, option.id];
if (props.value === undefined) {
latestValue.current = next;
setInternalValue(next);
}
props.onValueChange?.(next);
hideDetail();
} else {
if (props.value === undefined) setInternalValue(option.id);
props.onValueChange?.(option.id);
close();
}
};
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.nativeEvent.isComposing) return;
touchInteraction.current = false;
if (event.key !== "Tab") hideDetail();
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.target !== inputRef.current &&
!(event.target instanceof HTMLElement && event.target.getAttribute("role") === "option")) {
return;
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
for (let step = 0; step < 12; step++) moveActive(event.key === "PageDown" ? 1 : -1);
} else if (event.key === "Enter" && active) {
event.preventDefault();
select(active);
}
};
return (
<MorphPopover open={open} onOpenChange={changeOpen} className={cn("max-w-full", triggerVariant === "pill" ? "w-auto" : "w-64", className)}>
<div className={cn("w-full border border-border bg-background shadow-sm", triggerVariant === "pill" ? "rounded-3xl" : "rounded-xl")}>
<MorphPopoverTrigger>
<button
type="button"
ref={triggerRef}
className={cn("flex min-h-11 w-full items-center gap-2 rounded-[inherit] px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring", classNames?.trigger)}
>
{selected?.icon != null ? <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted", classNames?.icon)}>{selected.icon}</span> : null}
<span className="min-w-0 flex-1">
<span className={cn("block truncate font-medium", !selected && "text-muted-foreground")}>
{props.multiple ? (selectedOptions.length ? `${selectedOptions.length} selected` : placeholder) : selected?.name ?? placeholder}
</span>
{triggerVariant === "workspace" && selected?.description ? <span className="block truncate text-xs text-muted-foreground">{selected.description}</span> : null}
</span>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
>
<ChevronDown className="size-4" />
</motion.span>
</button>
</MorphPopoverTrigger>
{props.multiple && selectedOptions.length ? (
<div className="flex flex-wrap gap-1.5 px-2.5 pb-2.5">
{selectedOptions.map((option) => (
<span key={option.id} className="inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pr-0.5 pl-2 text-xs">
<span className="truncate">{option.name}</span>
<button type="button" aria-label={`Remove ${option.name}`} onClick={() => { select(option); triggerRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-3" /></button>
</span>
))}
</div>
) : null}
</div>
<MorphPopoverContent align="start" sideOffset={6} radius={16} onOpenAutoFocus={() => inputRef.current?.focus()} className={cn("flex w-[22rem] max-w-[calc(100vw-24px)] flex-col bg-background text-sm text-foreground", classNames?.content)}>
<div className="shrink-0 p-2 pb-1">
<div className={cn("flex h-10 items-center gap-2 rounded-lg bg-muted/60 px-3", classNames?.search)}>
<Search aria-hidden className="size-4 shrink-0 text-muted-foreground" />
<input ref={inputRef} onKeyDown={onKeyDown} value={query} onChange={(event) => { hideDetail(); setQuery(event.target.value); }} role="combobox" aria-label={searchPlaceholder} aria-expanded="true" aria-autocomplete="list" aria-controls={`${uid}-options`} aria-activedescendant={active ? `${uid}-option-${activeIndex}` : undefined} placeholder={searchPlaceholder} className="h-full min-w-0 flex-1 bg-transparent text-[inherit] outline-none placeholder:text-muted-foreground" />
{query ? <button type="button" aria-label="Clear search" onClick={() => { hideDetail(); setQuery(""); inputRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-4" /></button> : <kbd className="text-[10px] text-muted-foreground">ESC</kbd>}
</div>
</div>
<div ref={listRef} id={`${uid}-options`} role="listbox" aria-label={label} aria-multiselectable={props.multiple || undefined} onScroll={hideDetail} className={cn("max-h-[min(360px,calc(100dvh-176px))] min-h-0 overflow-y-auto overscroll-contain p-2", SCROLLBAR_CLASS, classNames?.list)}>
{groups.length ? groups.map((group) => (
<fieldset key={group.label} className="mb-2 min-w-0 border-0 p-0 last:mb-0">
{group.label ? <legend className="px-2 py-2 text-[11px] font-medium text-muted-foreground">{group.label}</legend> : null}
<div className={cn(layout === "grid" ? "grid grid-cols-2 gap-2" : "space-y-0.5")}>
{group.items.map((option) => {
const index = rows.indexOf(option);
const isActive = active?.id === option.id;
const isSelected = !option.disabled && selectedIds.has(option.id);
return (
<button key={option.id} id={index >= 0 ? `${uid}-option-${index}` : undefined} type="button" role="option" aria-selected={isSelected} aria-disabled={option.disabled || undefined} disabled={option.disabled} data-index={index >= 0 ? index : undefined} onKeyDown={onKeyDown} tabIndex={-1} aria-describedby={detail?.id === option.id ? `${uid}-details` : undefined}
onPointerEnter={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current || option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }}
onPointerDown={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current) hideDetail(); }} onPointerLeave={scheduleDetailClose}
onFocus={(event) => { if (option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }} onBlur={scheduleDetailClose} onClick={() => select(option)}
className={cn("relative flex min-h-12 w-full gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40", layout === "grid" ? "flex-col items-stretch border border-border" : "items-center", layout === "grid" && isSelected && "border-foreground", isActive && "bg-muted/70", classNames?.option)}>
{layout === "grid" ? <div className="flex aspect-[16/10] items-center justify-center overflow-hidden rounded-lg bg-muted/60 text-muted-foreground">{option.preview ?? option.icon ?? <span className="text-2xl font-medium">{option.name.slice(0, 1)}</span>}</div> : <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground", classNames?.icon)}>{option.icon ?? <span className="text-xs font-medium">{option.name.slice(0, 1)}</span>}</span>}
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{option.name}</span>{option.description ? <span className="block truncate text-xs text-muted-foreground">{option.description}</span> : null}</span>
{option.metadata ? <span className="shrink-0 text-[10px] text-muted-foreground">{option.metadata}</span> : null}
<span aria-hidden className={cn("flex size-4 shrink-0 items-center justify-center", props.multiple && "rounded border border-border", props.multiple && isSelected && "border-foreground bg-foreground text-background")}>{isSelected ? <Check className="size-3.5" /> : null}</span>
</span>
</button>
);
})}
</div>
</fieldset>
)) : <p className="px-3 py-8 text-center text-sm text-muted-foreground">No options found.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex flex-wrap gap-x-3 gap-y-1">{props.multiple ? <span>{selectedOptions.length} selected</span> : <><span><kbd>↑↓</kbd> Navigate</span><span><kbd>↵</kbd> Select</span></>}</span>
<div className="flex items-center gap-2">
{action ? <button type="button" onClick={() => { close(); action.onSelect(); }} className="rounded-md px-2 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring">{action.label}</button> : null}
{props.multiple ? <button type="button" onClick={close} className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background focus-visible:outline-2 focus-visible:outline-ring">Done</button> : null}
</div>
</div>
</MorphPopoverContent>
{detail ? createPortal(
<aside ref={detailRef} id={`${uid}-details`} aria-label="Option details" onKeyDown={onKeyDown} onPointerEnter={clearDetailTimer} onPointerLeave={scheduleDetailClose} onPointerDown={(event) => event.stopPropagation()} onFocus={clearDetailTimer} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) scheduleDetailClose(); }} className={cn("fixed z-[10000] max-h-[min(420px,70dvh)] w-[21rem] max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-background p-4 text-sm text-foreground shadow-xl", SCROLLBAR_CLASS, classNames?.details)} style={{ ...detailPosition, backgroundColor: "var(--background)" }}>{detail.details}</aside>, document.body,
) : null}
</MorphPopover>
);
}
People · Multiple select
selector.tsxSet multiple and triggerVariant="field" for an array of selected IDs. Choices stay open while you toggle them; remove individual chips from the field and press Done to close. Supply avatars through icon and emails through description. Disabled options cannot be selected.
Share with your team
Search names or emails, then select everyone who needs access.
Shared with 2 people.
Remove a chip to revoke access, or choose Done when finished.
"use client";
import { Bot, Brain, Building2, Circle, Sparkles } from "lucide-react";
import { useState } from "react";
import { Selector } from "@/components/motion/selector";
import type { SelectorOption } from "@/components/motion/selector";
const MODELS: SelectorOption[] = [
{
id: "claude-sonnet",
name: "Claude Sonnet",
group: "Anthropic",
description: "Balanced reasoning and everyday coding",
metadata: "200K",
keywords: ["reasoning", "vision", "tools"],
icon: <Brain className="size-4"/>,
details: (<div className="space-y-2">
<p className="font-medium">Claude Sonnet</p>
<p className="text-sm text-muted-foreground">
A balanced choice for writing, analysis, and everyday coding. Supports
images and tools with a 200K-token context window.
</p>
</div>),
},
{
id: "claude-opus",
name: "Claude Opus",
group: "Anthropic",
description: "Deep reasoning for complex work",
metadata: "200K",
keywords: ["reasoning", "analysis"],
icon: <Brain className="size-4"/>,
},
{
id: "gpt-4o",
name: "GPT-4o",
group: "OpenAI",
description: "Fast, multimodal conversations",
metadata: "128K",
keywords: ["vision", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gpt-4.1",
name: "GPT-4.1",
group: "OpenAI",
description: "Precise instructions and a larger context",
metadata: "1M",
keywords: ["coding", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gemini-pro",
name: "Gemini Pro",
group: "Google",
description: "Reason across documents, images, and code",
metadata: "1M",
keywords: ["vision", "reasoning"],
icon: <Bot className="size-4"/>,
},
];
export function SelectorPreview() {
const [model, setModel] = useState("claude-sonnet");
return (<div className="w-full max-w-md space-y-4">
<p className="font-medium text-sm">Choose your model</p>
<Selector options={MODELS} value={model} onValueChange={setModel} triggerVariant="pill" label="Models" searchPlaceholder="Search models…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Using {MODELS.find((option) => option.id === model)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Search by model, provider, or capability. Hover or focus Claude Sonnet
for details. Customize the trigger and panel with classNames.
</p>
</div>);
}
const PEOPLE: SelectorOption[] = [
{ id: "ava", name: "Ava Thompson", description: "ava@studio.co", metadata: "Design", group: "Your team" },
{ id: "noah", name: "Noah Williams", description: "noah@studio.co", metadata: "Engineering", group: "Your team" },
{ id: "mia", name: "Mia Chen", description: "mia@studio.co", metadata: "Product", group: "Your team" },
{ id: "leo", name: "Leo Martin", description: "leo@partner.co", metadata: "Guest", group: "Collaborators" },
].map((person) => ({
...person,
icon: (<span className="flex size-7 items-center justify-center rounded-full border bg-muted text-[10px] font-medium">
{person.name.split(" ").map((part) => part[0]).join("")}
</span>),
}));
export function SelectorPeopleSinglePreview() {
const [person, setPerson] = useState("");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Choose an assignee</p>
<p className="text-xs text-muted-foreground">
Search by name or email and choose one person responsible for the task.
</p>
</div>
<Selector options={PEOPLE} value={person} onValueChange={setPerson} placeholder="Assign to someone" searchPlaceholder="Search people…" label="Assignee"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{person ? `Assigned to ${PEOPLE.find((option) => option.id === person)?.name}.` : "No assignee yet."}
</p>
</div>);
}
export function SelectorPeoplePreview() {
const [people, setPeople] = useState<string[]>(["ava", "mia"]);
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Share with your team</p>
<p className="text-xs text-muted-foreground">
Search names or emails, then select everyone who needs access.
</p>
</div>
<Selector multiple options={PEOPLE} value={people} onValueChange={setPeople} triggerVariant="field" placeholder="Add people" searchPlaceholder="Search people…" label="People"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{people.length === 0
? "Only you have access."
: `Shared with ${people.length} ${people.length === 1 ? "person" : "people"}.`}
</p>
<p className="text-xs text-muted-foreground">
Remove a chip to revoke access, or choose Done when finished.
</p>
</div>);
}
const WORKSPACES: SelectorOption[] = [
{
id: "studio",
name: "Studio",
description: "Pro plan · 12 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "engineering",
name: "Engineering",
description: "Pro plan · 8 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "personal",
name: "Personal",
description: "Free plan · Just you",
group: "Personal workspaces",
icon: <Circle className="size-4"/>,
},
];
export function SelectorWorkspacePreview() {
const [options, setOptions] = useState(WORKSPACES);
const [workspace, setWorkspace] = useState("studio");
function createWorkspace() {
const number = options.length - WORKSPACES.length + 1;
const id = `new-workspace-${number}`;
setOptions([
...options,
{
id,
name: `New workspace ${number}`,
description: "Free plan · Just you",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
]);
setWorkspace(id);
}
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Your work, in one place</p>
<p className="text-xs text-muted-foreground">
Switch between team and personal workspaces.
</p>
</div>
<Selector options={options} value={workspace} onValueChange={setWorkspace} triggerVariant="workspace" searchPlaceholder="Find a workspace…" label="Workspaces" action={{ label: "Create workspace", onSelect: createWorkspace }}/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Current workspace: {options.find((option) => option.id === workspace)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Create workspace adds a new workspace and switches to it immediately.
New workspaces live in this demo until the page is refreshed.
</p>
</div>);
}
function TemplateThumbnail({ variant }: {
variant: "dashboard" | "editorial" | "board" | "portfolio";
}) {
return (<div aria-hidden="true" className="flex aspect-[16/10] w-full overflow-hidden rounded-lg border bg-background p-2 text-foreground">
{variant === "dashboard" ? (<>
<div className="mr-2 w-1/5 space-y-2 border-r pr-2">
<div className="h-2 w-3 rounded-sm bg-foreground/60"/>
{[0, 1, 2, 3].map((item) => <div key={item} className="h-1 rounded bg-foreground/15"/>)}
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="h-1.5 w-1/2 rounded bg-foreground/40"/>
<div className="grid grid-cols-3 gap-1">
{[0, 1, 2].map((item) => <div key={item} className="h-5 rounded border bg-foreground/5"/>)}
</div>
<div className="flex flex-1 items-end gap-1 rounded border p-1">
{[30, 55, 40, 75, 60, 90, 70].map((height) => <div key={height} className="flex-1 rounded-t-sm bg-foreground/25" style={{ height: `${height}%` }}/>)}
</div>
</div>
</>) : variant === "editorial" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1 w-full border-b"/>
<div className="mx-auto h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="mx-auto h-1 w-1/3 rounded bg-foreground/20"/>
<div className="flex flex-1 gap-2">
<div className="w-1/2 rounded bg-foreground/10"/>
<div className="flex-1 space-y-1.5 pt-1">
{[0, 1, 2, 3, 4].map((item) => <div key={item} className="h-1 rounded bg-foreground/15 last:w-2/3"/>)}
</div>
</div>
</div>) : variant === "board" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1.5 w-1/3 rounded bg-foreground/50"/>
<div className="grid flex-1 grid-cols-3 gap-1.5">
{[2, 3, 1].map((count) => (<div key={count} className="space-y-1 rounded bg-foreground/5 p-1">
<div className="mb-2 h-1 w-2/3 rounded bg-foreground/25"/>
{["first", "second", "third"].slice(0, count).map((card) => <div key={card} className="h-4 rounded-sm border bg-background"/>)}
</div>))}
</div>
</div>) : (<div className="flex w-full flex-col gap-2">
<div className="flex justify-between"><div className="h-1.5 w-6 rounded bg-foreground/50"/><div className="h-1 w-8 rounded bg-foreground/15"/></div>
<div className="mt-1 h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="h-1 w-1/2 rounded bg-foreground/20"/>
<div className="grid flex-1 grid-cols-2 gap-2"><div className="rounded bg-foreground/10"/><div className="rounded bg-foreground/20"/></div>
</div>)}
</div>);
}
const RESOURCES: SelectorOption[] = [
{ id: "dashboard", name: "Dashboard", description: "Metrics at a glance", metadata: "12 blocks", preview: <TemplateThumbnail variant="dashboard"/>, keywords: ["analytics", "charts"] },
{ id: "editorial", name: "Editorial", description: "Stories worth reading", metadata: "8 blocks", preview: <TemplateThumbnail variant="editorial"/>, keywords: ["blog", "writing"] },
{ id: "board", name: "Project board", description: "A little more organized", metadata: "6 blocks", preview: <TemplateThumbnail variant="board"/>, keywords: ["kanban", "tasks"] },
{ id: "portfolio", name: "Portfolio", description: "Make your work the focus", metadata: "10 blocks", preview: <TemplateThumbnail variant="portfolio"/>, keywords: ["gallery", "personal"] },
];
export function SelectorResourcesPreview() {
const [resource, setResource] = useState("dashboard");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Start with a template</p>
<p className="text-xs text-muted-foreground">
Browse the previews or search for a layout that fits your next project.
</p>
</div>
<Selector options={RESOURCES} value={resource} onValueChange={setResource} layout="grid" triggerVariant="field" classNames={{ content: "w-[30rem]", list: "max-h-[26rem]" }} label="Templates" searchPlaceholder="Search templates…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Selected template: {RESOURCES.find((option) => option.id === resource)?.name}.
</p>
</div>);
}
"use client";
// asharca.github.io/ui/components/blocks/selector
import { Check, ChevronDown, Search, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { SPRING_PRESS } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
export type SelectorOption = {
id: string;
name: string;
description?: string;
group?: string;
metadata?: string;
keywords?: string[];
icon?: ReactNode;
preview?: ReactNode;
details?: ReactNode;
disabled?: boolean;
};
/** Optional Tailwind overrides, merged after each slot's defaults. */
export type SelectorClassNames = {
trigger?: string;
content?: string;
list?: string;
option?: string;
icon?: string;
search?: string;
details?: string;
};
type SharedSelectorProps = {
options: SelectorOption[];
defaultOpen?: boolean;
placeholder?: string;
searchPlaceholder?: string;
label?: string;
triggerVariant?: "pill" | "field" | "workspace";
layout?: "list" | "grid";
action?: { label: string; onSelect: () => void };
className?: string;
/** Style individual parts independently; className styles the outer wrapper. */
classNames?: SelectorClassNames;
};
export type SelectorProps = SharedSelectorProps & (
| {
multiple?: false;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
| {
multiple: true;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
}
);
const SCROLLBAR_CLASS =
"[scrollbar-width:thin] [scrollbar-gutter:stable] [scrollbar-color:color-mix(in_oklab,var(--foreground)_18%,transparent)_transparent] hover:[scrollbar-color:color-mix(in_oklab,var(--foreground)_30%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb:hover]:bg-foreground/30";
export function Selector(props: SelectorProps) {
const {
options,
defaultOpen = false,
placeholder = "Select an option",
searchPlaceholder = "Search options",
label = "Options",
triggerVariant = "field",
layout = "list",
action,
className,
classNames,
} = props;
const uid = useId();
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState<string | string[]>(() =>
props.defaultValue ?? (props.multiple ? [] : options.find((option) => !option.disabled)?.id ?? ""),
);
const currentValue = props.value ?? internalValue;
const latestValue = useRef(currentValue);
useLayoutEffect(() => {
latestValue.current = currentValue;
});
const selectedIds = new Set(Array.isArray(currentValue) ? currentValue : [currentValue]);
const selectedOptions = options.filter((option) => !option.disabled && selectedIds.has(option.id));
const selected = selectedOptions[0];
const [open, setOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const detailRef = useRef<HTMLElement>(null);
const detailAnchor = useRef<HTMLElement | null>(null);
const detailTimer = useRef<number | undefined>(undefined);
const touchInteraction = useRef(false);
const [detailId, setDetailId] = useState<string | null>(null);
const [detailPosition, setDetailPosition] = useState({ top: 12, left: 12 });
const groups = useMemo(() => {
const result = new Map<string, SelectorOption[]>();
const search = query.trim().toLowerCase();
for (const option of options) {
if (![option.name, option.description, option.group, option.metadata, ...(option.keywords ?? [])]
.filter(Boolean).join(" ").toLowerCase().includes(search)) continue;
const group = option.group ?? "";
const items = result.get(group) ?? [];
items.push(option);
result.set(group, items);
}
return Array.from(result, ([label, items]) => ({ label, items }));
}, [options, query]);
const rows = useMemo(() => groups.flatMap((group) => group.items).filter((option) => !option.disabled), [groups]);
const { activeIndex, moveTo, moveActive } = useRowCursor(rows, query);
const active = rows[activeIndex];
const detail = open ? rows.find((option) => option.id === detailId && option.details != null) : undefined;
const clearDetailTimer = () => {
window.clearTimeout(detailTimer.current);
detailTimer.current = undefined;
};
const hideDetail = () => {
clearDetailTimer();
setDetailId(null);
};
const scheduleDetailClose = () => {
clearDetailTimer();
detailTimer.current = window.setTimeout(() => setDetailId(null), 100);
};
const showDetail = (option: SelectorOption, anchor: HTMLElement) => {
clearDetailTimer();
if (option.disabled || option.details == null || touchInteraction.current) {
setDetailId(null);
return;
}
if (detailId === option.id) return;
setDetailId(null);
detailTimer.current = window.setTimeout(() => {
detailAnchor.current = anchor;
setDetailId(option.id);
}, 1500);
};
useEffect(() => {
if (!open || rows.length === 0) window.clearTimeout(detailTimer.current);
return () => window.clearTimeout(detailTimer.current);
}, [open, rows]);
useLayoutEffect(() => {
if (!detail) return;
const place = () => {
const rect = detailAnchor.current?.getBoundingClientRect();
const card = detailRef.current;
if (!rect || !card) return;
const rightSpace = window.innerWidth - rect.right - 20;
const leftSpace = rect.left - 20;
const left = rightSpace >= card.offsetWidth || rightSpace >= leftSpace
? rect.right + 8 : rect.left - card.offsetWidth - 8;
setDetailPosition({
left: Math.max(12, Math.min(left, window.innerWidth - card.offsetWidth - 12)),
top: Math.max(12, Math.min(rect.top, window.innerHeight - card.offsetHeight - 12)),
});
};
place();
const observer = new ResizeObserver(place);
if (detailRef.current) observer.observe(detailRef.current);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [detail]);
useOnOpen(open, () => {
setQuery("");
setDetailId(null);
moveTo(selected?.id ?? null);
});
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
});
return () => cancelAnimationFrame(frame);
}, [open, activeIndex]);
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) {
hideDetail();
setQuery("");
}
};
const close = () => {
changeOpen(false);
triggerRef.current?.focus();
};
const select = (option: SelectorOption) => {
if (option.disabled) return;
if (props.multiple) {
const liveValue = props.value ?? latestValue.current;
const liveIds = Array.isArray(liveValue) ? liveValue : [];
const enabledIds = liveIds.filter((id) => options.some((item) => item.id === id && !item.disabled));
const next = enabledIds.includes(option.id)
? enabledIds.filter((id) => id !== option.id)
: [...enabledIds, option.id];
if (props.value === undefined) {
latestValue.current = next;
setInternalValue(next);
}
props.onValueChange?.(next);
hideDetail();
} else {
if (props.value === undefined) setInternalValue(option.id);
props.onValueChange?.(option.id);
close();
}
};
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.nativeEvent.isComposing) return;
touchInteraction.current = false;
if (event.key !== "Tab") hideDetail();
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.target !== inputRef.current &&
!(event.target instanceof HTMLElement && event.target.getAttribute("role") === "option")) {
return;
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
for (let step = 0; step < 12; step++) moveActive(event.key === "PageDown" ? 1 : -1);
} else if (event.key === "Enter" && active) {
event.preventDefault();
select(active);
}
};
return (
<MorphPopover open={open} onOpenChange={changeOpen} className={cn("max-w-full", triggerVariant === "pill" ? "w-auto" : "w-64", className)}>
<div className={cn("w-full border border-border bg-background shadow-sm", triggerVariant === "pill" ? "rounded-3xl" : "rounded-xl")}>
<MorphPopoverTrigger>
<button
type="button"
ref={triggerRef}
className={cn("flex min-h-11 w-full items-center gap-2 rounded-[inherit] px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring", classNames?.trigger)}
>
{selected?.icon != null ? <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted", classNames?.icon)}>{selected.icon}</span> : null}
<span className="min-w-0 flex-1">
<span className={cn("block truncate font-medium", !selected && "text-muted-foreground")}>
{props.multiple ? (selectedOptions.length ? `${selectedOptions.length} selected` : placeholder) : selected?.name ?? placeholder}
</span>
{triggerVariant === "workspace" && selected?.description ? <span className="block truncate text-xs text-muted-foreground">{selected.description}</span> : null}
</span>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
>
<ChevronDown className="size-4" />
</motion.span>
</button>
</MorphPopoverTrigger>
{props.multiple && selectedOptions.length ? (
<div className="flex flex-wrap gap-1.5 px-2.5 pb-2.5">
{selectedOptions.map((option) => (
<span key={option.id} className="inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pr-0.5 pl-2 text-xs">
<span className="truncate">{option.name}</span>
<button type="button" aria-label={`Remove ${option.name}`} onClick={() => { select(option); triggerRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-3" /></button>
</span>
))}
</div>
) : null}
</div>
<MorphPopoverContent align="start" sideOffset={6} radius={16} onOpenAutoFocus={() => inputRef.current?.focus()} className={cn("flex w-[22rem] max-w-[calc(100vw-24px)] flex-col bg-background text-sm text-foreground", classNames?.content)}>
<div className="shrink-0 p-2 pb-1">
<div className={cn("flex h-10 items-center gap-2 rounded-lg bg-muted/60 px-3", classNames?.search)}>
<Search aria-hidden className="size-4 shrink-0 text-muted-foreground" />
<input ref={inputRef} onKeyDown={onKeyDown} value={query} onChange={(event) => { hideDetail(); setQuery(event.target.value); }} role="combobox" aria-label={searchPlaceholder} aria-expanded="true" aria-autocomplete="list" aria-controls={`${uid}-options`} aria-activedescendant={active ? `${uid}-option-${activeIndex}` : undefined} placeholder={searchPlaceholder} className="h-full min-w-0 flex-1 bg-transparent text-[inherit] outline-none placeholder:text-muted-foreground" />
{query ? <button type="button" aria-label="Clear search" onClick={() => { hideDetail(); setQuery(""); inputRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-4" /></button> : <kbd className="text-[10px] text-muted-foreground">ESC</kbd>}
</div>
</div>
<div ref={listRef} id={`${uid}-options`} role="listbox" aria-label={label} aria-multiselectable={props.multiple || undefined} onScroll={hideDetail} className={cn("max-h-[min(360px,calc(100dvh-176px))] min-h-0 overflow-y-auto overscroll-contain p-2", SCROLLBAR_CLASS, classNames?.list)}>
{groups.length ? groups.map((group) => (
<fieldset key={group.label} className="mb-2 min-w-0 border-0 p-0 last:mb-0">
{group.label ? <legend className="px-2 py-2 text-[11px] font-medium text-muted-foreground">{group.label}</legend> : null}
<div className={cn(layout === "grid" ? "grid grid-cols-2 gap-2" : "space-y-0.5")}>
{group.items.map((option) => {
const index = rows.indexOf(option);
const isActive = active?.id === option.id;
const isSelected = !option.disabled && selectedIds.has(option.id);
return (
<button key={option.id} id={index >= 0 ? `${uid}-option-${index}` : undefined} type="button" role="option" aria-selected={isSelected} aria-disabled={option.disabled || undefined} disabled={option.disabled} data-index={index >= 0 ? index : undefined} onKeyDown={onKeyDown} tabIndex={-1} aria-describedby={detail?.id === option.id ? `${uid}-details` : undefined}
onPointerEnter={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current || option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }}
onPointerDown={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current) hideDetail(); }} onPointerLeave={scheduleDetailClose}
onFocus={(event) => { if (option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }} onBlur={scheduleDetailClose} onClick={() => select(option)}
className={cn("relative flex min-h-12 w-full gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40", layout === "grid" ? "flex-col items-stretch border border-border" : "items-center", layout === "grid" && isSelected && "border-foreground", isActive && "bg-muted/70", classNames?.option)}>
{layout === "grid" ? <div className="flex aspect-[16/10] items-center justify-center overflow-hidden rounded-lg bg-muted/60 text-muted-foreground">{option.preview ?? option.icon ?? <span className="text-2xl font-medium">{option.name.slice(0, 1)}</span>}</div> : <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground", classNames?.icon)}>{option.icon ?? <span className="text-xs font-medium">{option.name.slice(0, 1)}</span>}</span>}
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{option.name}</span>{option.description ? <span className="block truncate text-xs text-muted-foreground">{option.description}</span> : null}</span>
{option.metadata ? <span className="shrink-0 text-[10px] text-muted-foreground">{option.metadata}</span> : null}
<span aria-hidden className={cn("flex size-4 shrink-0 items-center justify-center", props.multiple && "rounded border border-border", props.multiple && isSelected && "border-foreground bg-foreground text-background")}>{isSelected ? <Check className="size-3.5" /> : null}</span>
</span>
</button>
);
})}
</div>
</fieldset>
)) : <p className="px-3 py-8 text-center text-sm text-muted-foreground">No options found.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex flex-wrap gap-x-3 gap-y-1">{props.multiple ? <span>{selectedOptions.length} selected</span> : <><span><kbd>↑↓</kbd> Navigate</span><span><kbd>↵</kbd> Select</span></>}</span>
<div className="flex items-center gap-2">
{action ? <button type="button" onClick={() => { close(); action.onSelect(); }} className="rounded-md px-2 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring">{action.label}</button> : null}
{props.multiple ? <button type="button" onClick={close} className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background focus-visible:outline-2 focus-visible:outline-ring">Done</button> : null}
</div>
</div>
</MorphPopoverContent>
{detail ? createPortal(
<aside ref={detailRef} id={`${uid}-details`} aria-label="Option details" onKeyDown={onKeyDown} onPointerEnter={clearDetailTimer} onPointerLeave={scheduleDetailClose} onPointerDown={(event) => event.stopPropagation()} onFocus={clearDetailTimer} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) scheduleDetailClose(); }} className={cn("fixed z-[10000] max-h-[min(420px,70dvh)] w-[21rem] max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-background p-4 text-sm text-foreground shadow-xl", SCROLLBAR_CLASS, classNames?.details)} style={{ ...detailPosition, backgroundColor: "var(--background)" }}>{detail.details}</aside>, document.body,
) : null}
</MorphPopover>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
"use client";
import { useState } from "react";
/**
* Runs `start` during the render in which `open` becomes true.
*
* Opening a list starts a fresh session — an empty query, the highlight back at
* the top — and that is a resolution, not a side effect: a passive effect runs
* after the commit, so the new session would carry the last one's state for a
* window in which a key press can land.
*
* `start` may only set state belonging to the calling component. Anything that
* reaches outside it — a consumer's callback, a DOM write, moving focus — is a
* side effect, and React rejects a callback that sets another component's state
* during this one's render. Put those in an effect keyed to `open`.
*/
export function useOnOpen(open: boolean, start: () => void) {
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) start();
}
}
"use client";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
/**
* Where the keyboard or the pointer last moved to: the row's id, stamped with
* the query it was placed under.
*/
type RowCursor = { id: string; query: string };
/** The cursor's row, or -1 once the query has moved on or the row has left. */
function indexOfCursor(
rows: readonly { id: string }[],
query: string,
cursor: RowCursor | null,
) {
if (cursor === null || cursor.query !== query) return -1;
return rows.findIndex((row) => row.id === cursor.id);
}
/**
* The highlighted row of a list whose rows can change under it.
*
* Which row is highlighted is resolved during render, never in a passive
* effect: a passive effect runs after the commit, so a list that had just
* changed would carry an `aria-activedescendant` naming a row that has left it,
* and a key pressed in that window would commit the wrong row or nothing at
* all.
*
* The cursor holds the row's id, not its position. A position alone cannot tell
* a list that shrank from one that swapped its rows for a different set of the
* same length, and the second case is the one that silently hands Enter to a
* row the user never chose. A cursor whose row has left the list returns the
* highlight to the first row rather than to the nearest surviving one: the row
* the user aimed at is gone, and the first row is where a new query already
* puts the highlight.
*
* Pass the query the list is filtered by. The cursor is stamped with it and
* dropped when it changes, so a caller cannot forget to clear it — including a
* caller whose query arrives as a prop and so never runs its own handler. Rows
* that come back under a query that has moved on cannot revive it either.
* `moveTo(null)` is for deliberate resets, such as reopening the list.
*
* Both callbacks keep one identity for the life of the component, and read the
* rows and the query through a ref to do it. A caller will put them in an
* effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`
* rebuilt on every keystroke would re-run that effect on every keystroke.
*/
export function useRowCursor(rows: readonly { id: string }[], query: string) {
const [cursor, setCursor] = useState<RowCursor | null>(null);
// Written after commit, not during render: a render React discards or has not
// finished still runs the component body, and an event handler that read this
// in that window would stamp the cursor with a query the committed tree does
// not have.
const latest = useRef({ rows, query });
useLayoutEffect(() => {
latest.current = { rows, query };
});
const cursorRow = indexOfCursor(rows, query, cursor);
// Cleared rather than ignored: React re-runs this render with the cursor
// already gone, so rows that come back cannot revive a highlight the user has
// stopped aiming at.
if (cursor !== null && cursorRow < 0) setCursor(null);
const moveTo = useCallback(
(id: string | null) =>
setCursor(id === null ? null : { id, query: latest.current.query }),
[],
);
const moveActive = useCallback((direction: 1 | -1) => {
const { rows: live, query: liveQuery } = latest.current;
const last = live.length - 1;
if (last < 0) return;
// Steps from the row the cursor is really on, inside the update, so that
// two keys landing in one batch move two rows rather than one.
setCursor((current) => {
const at = Math.max(indexOfCursor(live, liveQuery, current), 0);
const next = Math.min(Math.max(at + direction, 0), last);
return { id: live[next].id, query: liveQuery };
});
}, []);
return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// asharca.github.io/ui/components/blocks/selector
import { Check, ChevronDown, Search, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { SPRING_PRESS } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
export type SelectorOption = {
id: string;
name: string;
description?: string;
group?: string;
metadata?: string;
keywords?: string[];
icon?: ReactNode;
preview?: ReactNode;
details?: ReactNode;
disabled?: boolean;
};
/** Optional Tailwind overrides, merged after each slot's defaults. */
export type SelectorClassNames = {
trigger?: string;
content?: string;
list?: string;
option?: string;
icon?: string;
search?: string;
details?: string;
};
type SharedSelectorProps = {
options: SelectorOption[];
defaultOpen?: boolean;
placeholder?: string;
searchPlaceholder?: string;
label?: string;
triggerVariant?: "pill" | "field" | "workspace";
layout?: "list" | "grid";
action?: { label: string; onSelect: () => void };
className?: string;
/** Style individual parts independently; className styles the outer wrapper. */
classNames?: SelectorClassNames;
};
export type SelectorProps = SharedSelectorProps & (
| {
multiple?: false;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
| {
multiple: true;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
}
);
const SCROLLBAR_CLASS =
"[scrollbar-width:thin] [scrollbar-gutter:stable] [scrollbar-color:color-mix(in_oklab,var(--foreground)_18%,transparent)_transparent] hover:[scrollbar-color:color-mix(in_oklab,var(--foreground)_30%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb:hover]:bg-foreground/30";
export function Selector(props: SelectorProps) {
const {
options,
defaultOpen = false,
placeholder = "Select an option",
searchPlaceholder = "Search options",
label = "Options",
triggerVariant = "field",
layout = "list",
action,
className,
classNames,
} = props;
const uid = useId();
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState<string | string[]>(() =>
props.defaultValue ?? (props.multiple ? [] : options.find((option) => !option.disabled)?.id ?? ""),
);
const currentValue = props.value ?? internalValue;
const latestValue = useRef(currentValue);
useLayoutEffect(() => {
latestValue.current = currentValue;
});
const selectedIds = new Set(Array.isArray(currentValue) ? currentValue : [currentValue]);
const selectedOptions = options.filter((option) => !option.disabled && selectedIds.has(option.id));
const selected = selectedOptions[0];
const [open, setOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const detailRef = useRef<HTMLElement>(null);
const detailAnchor = useRef<HTMLElement | null>(null);
const detailTimer = useRef<number | undefined>(undefined);
const touchInteraction = useRef(false);
const [detailId, setDetailId] = useState<string | null>(null);
const [detailPosition, setDetailPosition] = useState({ top: 12, left: 12 });
const groups = useMemo(() => {
const result = new Map<string, SelectorOption[]>();
const search = query.trim().toLowerCase();
for (const option of options) {
if (![option.name, option.description, option.group, option.metadata, ...(option.keywords ?? [])]
.filter(Boolean).join(" ").toLowerCase().includes(search)) continue;
const group = option.group ?? "";
const items = result.get(group) ?? [];
items.push(option);
result.set(group, items);
}
return Array.from(result, ([label, items]) => ({ label, items }));
}, [options, query]);
const rows = useMemo(() => groups.flatMap((group) => group.items).filter((option) => !option.disabled), [groups]);
const { activeIndex, moveTo, moveActive } = useRowCursor(rows, query);
const active = rows[activeIndex];
const detail = open ? rows.find((option) => option.id === detailId && option.details != null) : undefined;
const clearDetailTimer = () => {
window.clearTimeout(detailTimer.current);
detailTimer.current = undefined;
};
const hideDetail = () => {
clearDetailTimer();
setDetailId(null);
};
const scheduleDetailClose = () => {
clearDetailTimer();
detailTimer.current = window.setTimeout(() => setDetailId(null), 100);
};
const showDetail = (option: SelectorOption, anchor: HTMLElement) => {
clearDetailTimer();
if (option.disabled || option.details == null || touchInteraction.current) {
setDetailId(null);
return;
}
if (detailId === option.id) return;
setDetailId(null);
detailTimer.current = window.setTimeout(() => {
detailAnchor.current = anchor;
setDetailId(option.id);
}, 1500);
};
useEffect(() => {
if (!open || rows.length === 0) window.clearTimeout(detailTimer.current);
return () => window.clearTimeout(detailTimer.current);
}, [open, rows]);
useLayoutEffect(() => {
if (!detail) return;
const place = () => {
const rect = detailAnchor.current?.getBoundingClientRect();
const card = detailRef.current;
if (!rect || !card) return;
const rightSpace = window.innerWidth - rect.right - 20;
const leftSpace = rect.left - 20;
const left = rightSpace >= card.offsetWidth || rightSpace >= leftSpace
? rect.right + 8 : rect.left - card.offsetWidth - 8;
setDetailPosition({
left: Math.max(12, Math.min(left, window.innerWidth - card.offsetWidth - 12)),
top: Math.max(12, Math.min(rect.top, window.innerHeight - card.offsetHeight - 12)),
});
};
place();
const observer = new ResizeObserver(place);
if (detailRef.current) observer.observe(detailRef.current);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [detail]);
useOnOpen(open, () => {
setQuery("");
setDetailId(null);
moveTo(selected?.id ?? null);
});
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
});
return () => cancelAnimationFrame(frame);
}, [open, activeIndex]);
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) {
hideDetail();
setQuery("");
}
};
const close = () => {
changeOpen(false);
triggerRef.current?.focus();
};
const select = (option: SelectorOption) => {
if (option.disabled) return;
if (props.multiple) {
const liveValue = props.value ?? latestValue.current;
const liveIds = Array.isArray(liveValue) ? liveValue : [];
const enabledIds = liveIds.filter((id) => options.some((item) => item.id === id && !item.disabled));
const next = enabledIds.includes(option.id)
? enabledIds.filter((id) => id !== option.id)
: [...enabledIds, option.id];
if (props.value === undefined) {
latestValue.current = next;
setInternalValue(next);
}
props.onValueChange?.(next);
hideDetail();
} else {
if (props.value === undefined) setInternalValue(option.id);
props.onValueChange?.(option.id);
close();
}
};
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.nativeEvent.isComposing) return;
touchInteraction.current = false;
if (event.key !== "Tab") hideDetail();
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.target !== inputRef.current &&
!(event.target instanceof HTMLElement && event.target.getAttribute("role") === "option")) {
return;
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
for (let step = 0; step < 12; step++) moveActive(event.key === "PageDown" ? 1 : -1);
} else if (event.key === "Enter" && active) {
event.preventDefault();
select(active);
}
};
return (
<MorphPopover open={open} onOpenChange={changeOpen} className={cn("max-w-full", triggerVariant === "pill" ? "w-auto" : "w-64", className)}>
<div className={cn("w-full border border-border bg-background shadow-sm", triggerVariant === "pill" ? "rounded-3xl" : "rounded-xl")}>
<MorphPopoverTrigger>
<button
type="button"
ref={triggerRef}
className={cn("flex min-h-11 w-full items-center gap-2 rounded-[inherit] px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring", classNames?.trigger)}
>
{selected?.icon != null ? <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted", classNames?.icon)}>{selected.icon}</span> : null}
<span className="min-w-0 flex-1">
<span className={cn("block truncate font-medium", !selected && "text-muted-foreground")}>
{props.multiple ? (selectedOptions.length ? `${selectedOptions.length} selected` : placeholder) : selected?.name ?? placeholder}
</span>
{triggerVariant === "workspace" && selected?.description ? <span className="block truncate text-xs text-muted-foreground">{selected.description}</span> : null}
</span>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
>
<ChevronDown className="size-4" />
</motion.span>
</button>
</MorphPopoverTrigger>
{props.multiple && selectedOptions.length ? (
<div className="flex flex-wrap gap-1.5 px-2.5 pb-2.5">
{selectedOptions.map((option) => (
<span key={option.id} className="inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pr-0.5 pl-2 text-xs">
<span className="truncate">{option.name}</span>
<button type="button" aria-label={`Remove ${option.name}`} onClick={() => { select(option); triggerRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-3" /></button>
</span>
))}
</div>
) : null}
</div>
<MorphPopoverContent align="start" sideOffset={6} radius={16} onOpenAutoFocus={() => inputRef.current?.focus()} className={cn("flex w-[22rem] max-w-[calc(100vw-24px)] flex-col bg-background text-sm text-foreground", classNames?.content)}>
<div className="shrink-0 p-2 pb-1">
<div className={cn("flex h-10 items-center gap-2 rounded-lg bg-muted/60 px-3", classNames?.search)}>
<Search aria-hidden className="size-4 shrink-0 text-muted-foreground" />
<input ref={inputRef} onKeyDown={onKeyDown} value={query} onChange={(event) => { hideDetail(); setQuery(event.target.value); }} role="combobox" aria-label={searchPlaceholder} aria-expanded="true" aria-autocomplete="list" aria-controls={`${uid}-options`} aria-activedescendant={active ? `${uid}-option-${activeIndex}` : undefined} placeholder={searchPlaceholder} className="h-full min-w-0 flex-1 bg-transparent text-[inherit] outline-none placeholder:text-muted-foreground" />
{query ? <button type="button" aria-label="Clear search" onClick={() => { hideDetail(); setQuery(""); inputRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-4" /></button> : <kbd className="text-[10px] text-muted-foreground">ESC</kbd>}
</div>
</div>
<div ref={listRef} id={`${uid}-options`} role="listbox" aria-label={label} aria-multiselectable={props.multiple || undefined} onScroll={hideDetail} className={cn("max-h-[min(360px,calc(100dvh-176px))] min-h-0 overflow-y-auto overscroll-contain p-2", SCROLLBAR_CLASS, classNames?.list)}>
{groups.length ? groups.map((group) => (
<fieldset key={group.label} className="mb-2 min-w-0 border-0 p-0 last:mb-0">
{group.label ? <legend className="px-2 py-2 text-[11px] font-medium text-muted-foreground">{group.label}</legend> : null}
<div className={cn(layout === "grid" ? "grid grid-cols-2 gap-2" : "space-y-0.5")}>
{group.items.map((option) => {
const index = rows.indexOf(option);
const isActive = active?.id === option.id;
const isSelected = !option.disabled && selectedIds.has(option.id);
return (
<button key={option.id} id={index >= 0 ? `${uid}-option-${index}` : undefined} type="button" role="option" aria-selected={isSelected} aria-disabled={option.disabled || undefined} disabled={option.disabled} data-index={index >= 0 ? index : undefined} onKeyDown={onKeyDown} tabIndex={-1} aria-describedby={detail?.id === option.id ? `${uid}-details` : undefined}
onPointerEnter={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current || option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }}
onPointerDown={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current) hideDetail(); }} onPointerLeave={scheduleDetailClose}
onFocus={(event) => { if (option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }} onBlur={scheduleDetailClose} onClick={() => select(option)}
className={cn("relative flex min-h-12 w-full gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40", layout === "grid" ? "flex-col items-stretch border border-border" : "items-center", layout === "grid" && isSelected && "border-foreground", isActive && "bg-muted/70", classNames?.option)}>
{layout === "grid" ? <div className="flex aspect-[16/10] items-center justify-center overflow-hidden rounded-lg bg-muted/60 text-muted-foreground">{option.preview ?? option.icon ?? <span className="text-2xl font-medium">{option.name.slice(0, 1)}</span>}</div> : <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground", classNames?.icon)}>{option.icon ?? <span className="text-xs font-medium">{option.name.slice(0, 1)}</span>}</span>}
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{option.name}</span>{option.description ? <span className="block truncate text-xs text-muted-foreground">{option.description}</span> : null}</span>
{option.metadata ? <span className="shrink-0 text-[10px] text-muted-foreground">{option.metadata}</span> : null}
<span aria-hidden className={cn("flex size-4 shrink-0 items-center justify-center", props.multiple && "rounded border border-border", props.multiple && isSelected && "border-foreground bg-foreground text-background")}>{isSelected ? <Check className="size-3.5" /> : null}</span>
</span>
</button>
);
})}
</div>
</fieldset>
)) : <p className="px-3 py-8 text-center text-sm text-muted-foreground">No options found.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex flex-wrap gap-x-3 gap-y-1">{props.multiple ? <span>{selectedOptions.length} selected</span> : <><span><kbd>↑↓</kbd> Navigate</span><span><kbd>↵</kbd> Select</span></>}</span>
<div className="flex items-center gap-2">
{action ? <button type="button" onClick={() => { close(); action.onSelect(); }} className="rounded-md px-2 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring">{action.label}</button> : null}
{props.multiple ? <button type="button" onClick={close} className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background focus-visible:outline-2 focus-visible:outline-ring">Done</button> : null}
</div>
</div>
</MorphPopoverContent>
{detail ? createPortal(
<aside ref={detailRef} id={`${uid}-details`} aria-label="Option details" onKeyDown={onKeyDown} onPointerEnter={clearDetailTimer} onPointerLeave={scheduleDetailClose} onPointerDown={(event) => event.stopPropagation()} onFocus={clearDetailTimer} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) scheduleDetailClose(); }} className={cn("fixed z-[10000] max-h-[min(420px,70dvh)] w-[21rem] max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-background p-4 text-sm text-foreground shadow-xl", SCROLLBAR_CLASS, classNames?.details)} style={{ ...detailPosition, backgroundColor: "var(--background)" }}>{detail.details}</aside>, document.body,
) : null}
</MorphPopover>
);
}
"use client";
import {
AnimatePresence,
motion,
animate,
useMotionValue,
usePresence,
useReducedMotion,
} from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
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 React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child?.props?.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child?.props as { ref?: Ref<HTMLElement> } | undefined)
?.ref;
// Register once per actual ref change, not once per open-state render.
const mergedRef = useMemo(
() => mergeRefs(childRef, ctx.registerTrigger),
[childRef, ctx.registerTrigger],
);
if (!isValidElement(children)) return children;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergedRef,
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipAt(side: Side, align: Align, radius: number, inset: number) {
const top = side === "bottom" ? "0%" : `${inset}%`;
const bottom = side === "bottom" ? `${inset}%` : "0%";
const right = align === "end" ? "0%" : `${inset}%`;
const left = align === "end" ? `${inset}%` : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
/** Draw the surface shadow. Default true. */
shadow?: boolean;
/** Runs after the portalled surface is positioned and visible. */
onOpenAutoFocus?: (content: HTMLDivElement) => void;
/** Override the panel landmark. Menus pass "menu" instead of the dialog default. */
role?: "dialog" | "menu";
className?: string;
}
export function MorphPopoverContent(props: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const [portalReady, setPortalReady] = useState(false);
useEffect(() => setPortalReady(true), []);
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open && <MorphPopoverSurface {...props} />}
</AnimatePresence>,
document.body,
);
}
// Measurement belongs to the mounted portal session: reopening must not start
// an entrance at the previous session's coordinates before measuring this one.
function MorphPopoverSurface({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
shadow = true,
onOpenAutoFocus,
role = "dialog",
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [isPresent, safeToRemove] = usePresence();
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
isPresent,
);
const preferredLeft = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
// Larger compositions, such as calendars, use the roomier side if needed.
const gutter = 8;
const below = layout
? Math.max(0, window.innerHeight - layout.trigger.top - layout.trigger.height - sideOffset - gutter)
: 0;
const above = layout ? Math.max(0, layout.trigger.top - sideOffset - gutter) : 0;
const requestedSpace = side === "bottom" ? below : above;
const otherSpace = side === "bottom" ? above : below;
const resolvedSide = layout && layout.content.height > requestedSpace && otherSpace > requestedSpace
? side === "bottom" ? "top" : "bottom"
: side;
const availableHeight = resolvedSide === "bottom" ? below : above;
const left = layout
? Math.max(gutter, Math.min(preferredLeft, window.innerWidth - layout.content.width - gutter))
: 0;
const top = layout
? resolvedSide === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: Math.max(gutter, layout.trigger.top - layout.content.height - sideOffset)
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { scale: 0.96, transition: SPRING_PANEL },
show: { scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipAt(resolvedSide, align, radius, 92),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipAt(resolvedSide, align, radius, 0),
transition: MORPH_CLIP_TRANSITION,
},
};
// Animate the value directly so opacity stays in the inline style throughout
// the entrance. A native opacity animation can expose the initial inline 0
// for a frame when it finishes, before Motion writes the final value.
const opacity = useMotionValue(0);
const ready = layout !== null;
const focusedOnOpen = useRef(false);
useEffect(() => {
if (!ready || !isPresent || focusedOnOpen.current || !ctx.contentRef.current) return;
focusedOnOpen.current = true;
onOpenAutoFocus?.(ctx.contentRef.current);
}, [ready, isPresent, ctx.contentRef, onOpenAutoFocus]);
useEffect(() => {
if (!ready) {
if (!isPresent) safeToRemove?.();
return;
}
const animation = animate(opacity, isPresent ? 1 : 0, {
...(reduce ? { duration: 0.12 } : SPRING_PANEL),
onComplete: () => {
if (!isPresent) safeToRemove?.();
},
});
return () => animation.stop();
}, [opacity, ready, isPresent, reduce, safeToRemove]);
return (
<motion.div
data-morph-popover-portal=""
inert={!isPresent}
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial="hidden"
animate={layout ? "show" : "hidden"}
exit="hidden"
style={{
left,
top,
opacity,
pointerEvents: isPresent ? "auto" : "none",
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(resolvedSide, align),
}}
className={cn("fixed z-[9999]", shadow && "[filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]")}
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role={role}
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius, maxHeight: layout ? availableHeight : undefined, overflowY: "auto" }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
);
}
"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;
}
Workspace · Switcher
selector.tsxUse triggerVariant="workspace" to show the selected workspace and its description together. Groups separate team and personal spaces. An optional action with label/onSelect renders a separate footer action; the example creates and selects a new workspace.
Your work, in one place
Switch between team and personal workspaces.
Current workspace: Studio.
Create workspace adds a new workspace and switches to it immediately. New workspaces live in this demo until the page is refreshed.
"use client";
import { Bot, Brain, Building2, Circle, Sparkles } from "lucide-react";
import { useState } from "react";
import { Selector } from "@/components/motion/selector";
import type { SelectorOption } from "@/components/motion/selector";
const MODELS: SelectorOption[] = [
{
id: "claude-sonnet",
name: "Claude Sonnet",
group: "Anthropic",
description: "Balanced reasoning and everyday coding",
metadata: "200K",
keywords: ["reasoning", "vision", "tools"],
icon: <Brain className="size-4"/>,
details: (<div className="space-y-2">
<p className="font-medium">Claude Sonnet</p>
<p className="text-sm text-muted-foreground">
A balanced choice for writing, analysis, and everyday coding. Supports
images and tools with a 200K-token context window.
</p>
</div>),
},
{
id: "claude-opus",
name: "Claude Opus",
group: "Anthropic",
description: "Deep reasoning for complex work",
metadata: "200K",
keywords: ["reasoning", "analysis"],
icon: <Brain className="size-4"/>,
},
{
id: "gpt-4o",
name: "GPT-4o",
group: "OpenAI",
description: "Fast, multimodal conversations",
metadata: "128K",
keywords: ["vision", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gpt-4.1",
name: "GPT-4.1",
group: "OpenAI",
description: "Precise instructions and a larger context",
metadata: "1M",
keywords: ["coding", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gemini-pro",
name: "Gemini Pro",
group: "Google",
description: "Reason across documents, images, and code",
metadata: "1M",
keywords: ["vision", "reasoning"],
icon: <Bot className="size-4"/>,
},
];
export function SelectorPreview() {
const [model, setModel] = useState("claude-sonnet");
return (<div className="w-full max-w-md space-y-4">
<p className="font-medium text-sm">Choose your model</p>
<Selector options={MODELS} value={model} onValueChange={setModel} triggerVariant="pill" label="Models" searchPlaceholder="Search models…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Using {MODELS.find((option) => option.id === model)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Search by model, provider, or capability. Hover or focus Claude Sonnet
for details. Customize the trigger and panel with classNames.
</p>
</div>);
}
const PEOPLE: SelectorOption[] = [
{ id: "ava", name: "Ava Thompson", description: "ava@studio.co", metadata: "Design", group: "Your team" },
{ id: "noah", name: "Noah Williams", description: "noah@studio.co", metadata: "Engineering", group: "Your team" },
{ id: "mia", name: "Mia Chen", description: "mia@studio.co", metadata: "Product", group: "Your team" },
{ id: "leo", name: "Leo Martin", description: "leo@partner.co", metadata: "Guest", group: "Collaborators" },
].map((person) => ({
...person,
icon: (<span className="flex size-7 items-center justify-center rounded-full border bg-muted text-[10px] font-medium">
{person.name.split(" ").map((part) => part[0]).join("")}
</span>),
}));
export function SelectorPeopleSinglePreview() {
const [person, setPerson] = useState("");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Choose an assignee</p>
<p className="text-xs text-muted-foreground">
Search by name or email and choose one person responsible for the task.
</p>
</div>
<Selector options={PEOPLE} value={person} onValueChange={setPerson} placeholder="Assign to someone" searchPlaceholder="Search people…" label="Assignee"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{person ? `Assigned to ${PEOPLE.find((option) => option.id === person)?.name}.` : "No assignee yet."}
</p>
</div>);
}
export function SelectorPeoplePreview() {
const [people, setPeople] = useState<string[]>(["ava", "mia"]);
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Share with your team</p>
<p className="text-xs text-muted-foreground">
Search names or emails, then select everyone who needs access.
</p>
</div>
<Selector multiple options={PEOPLE} value={people} onValueChange={setPeople} triggerVariant="field" placeholder="Add people" searchPlaceholder="Search people…" label="People"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{people.length === 0
? "Only you have access."
: `Shared with ${people.length} ${people.length === 1 ? "person" : "people"}.`}
</p>
<p className="text-xs text-muted-foreground">
Remove a chip to revoke access, or choose Done when finished.
</p>
</div>);
}
const WORKSPACES: SelectorOption[] = [
{
id: "studio",
name: "Studio",
description: "Pro plan · 12 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "engineering",
name: "Engineering",
description: "Pro plan · 8 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "personal",
name: "Personal",
description: "Free plan · Just you",
group: "Personal workspaces",
icon: <Circle className="size-4"/>,
},
];
export function SelectorWorkspacePreview() {
const [options, setOptions] = useState(WORKSPACES);
const [workspace, setWorkspace] = useState("studio");
function createWorkspace() {
const number = options.length - WORKSPACES.length + 1;
const id = `new-workspace-${number}`;
setOptions([
...options,
{
id,
name: `New workspace ${number}`,
description: "Free plan · Just you",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
]);
setWorkspace(id);
}
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Your work, in one place</p>
<p className="text-xs text-muted-foreground">
Switch between team and personal workspaces.
</p>
</div>
<Selector options={options} value={workspace} onValueChange={setWorkspace} triggerVariant="workspace" searchPlaceholder="Find a workspace…" label="Workspaces" action={{ label: "Create workspace", onSelect: createWorkspace }}/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Current workspace: {options.find((option) => option.id === workspace)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Create workspace adds a new workspace and switches to it immediately.
New workspaces live in this demo until the page is refreshed.
</p>
</div>);
}
function TemplateThumbnail({ variant }: {
variant: "dashboard" | "editorial" | "board" | "portfolio";
}) {
return (<div aria-hidden="true" className="flex aspect-[16/10] w-full overflow-hidden rounded-lg border bg-background p-2 text-foreground">
{variant === "dashboard" ? (<>
<div className="mr-2 w-1/5 space-y-2 border-r pr-2">
<div className="h-2 w-3 rounded-sm bg-foreground/60"/>
{[0, 1, 2, 3].map((item) => <div key={item} className="h-1 rounded bg-foreground/15"/>)}
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="h-1.5 w-1/2 rounded bg-foreground/40"/>
<div className="grid grid-cols-3 gap-1">
{[0, 1, 2].map((item) => <div key={item} className="h-5 rounded border bg-foreground/5"/>)}
</div>
<div className="flex flex-1 items-end gap-1 rounded border p-1">
{[30, 55, 40, 75, 60, 90, 70].map((height) => <div key={height} className="flex-1 rounded-t-sm bg-foreground/25" style={{ height: `${height}%` }}/>)}
</div>
</div>
</>) : variant === "editorial" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1 w-full border-b"/>
<div className="mx-auto h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="mx-auto h-1 w-1/3 rounded bg-foreground/20"/>
<div className="flex flex-1 gap-2">
<div className="w-1/2 rounded bg-foreground/10"/>
<div className="flex-1 space-y-1.5 pt-1">
{[0, 1, 2, 3, 4].map((item) => <div key={item} className="h-1 rounded bg-foreground/15 last:w-2/3"/>)}
</div>
</div>
</div>) : variant === "board" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1.5 w-1/3 rounded bg-foreground/50"/>
<div className="grid flex-1 grid-cols-3 gap-1.5">
{[2, 3, 1].map((count) => (<div key={count} className="space-y-1 rounded bg-foreground/5 p-1">
<div className="mb-2 h-1 w-2/3 rounded bg-foreground/25"/>
{["first", "second", "third"].slice(0, count).map((card) => <div key={card} className="h-4 rounded-sm border bg-background"/>)}
</div>))}
</div>
</div>) : (<div className="flex w-full flex-col gap-2">
<div className="flex justify-between"><div className="h-1.5 w-6 rounded bg-foreground/50"/><div className="h-1 w-8 rounded bg-foreground/15"/></div>
<div className="mt-1 h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="h-1 w-1/2 rounded bg-foreground/20"/>
<div className="grid flex-1 grid-cols-2 gap-2"><div className="rounded bg-foreground/10"/><div className="rounded bg-foreground/20"/></div>
</div>)}
</div>);
}
const RESOURCES: SelectorOption[] = [
{ id: "dashboard", name: "Dashboard", description: "Metrics at a glance", metadata: "12 blocks", preview: <TemplateThumbnail variant="dashboard"/>, keywords: ["analytics", "charts"] },
{ id: "editorial", name: "Editorial", description: "Stories worth reading", metadata: "8 blocks", preview: <TemplateThumbnail variant="editorial"/>, keywords: ["blog", "writing"] },
{ id: "board", name: "Project board", description: "A little more organized", metadata: "6 blocks", preview: <TemplateThumbnail variant="board"/>, keywords: ["kanban", "tasks"] },
{ id: "portfolio", name: "Portfolio", description: "Make your work the focus", metadata: "10 blocks", preview: <TemplateThumbnail variant="portfolio"/>, keywords: ["gallery", "personal"] },
];
export function SelectorResourcesPreview() {
const [resource, setResource] = useState("dashboard");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Start with a template</p>
<p className="text-xs text-muted-foreground">
Browse the previews or search for a layout that fits your next project.
</p>
</div>
<Selector options={RESOURCES} value={resource} onValueChange={setResource} layout="grid" triggerVariant="field" classNames={{ content: "w-[30rem]", list: "max-h-[26rem]" }} label="Templates" searchPlaceholder="Search templates…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Selected template: {RESOURCES.find((option) => option.id === resource)?.name}.
</p>
</div>);
}
"use client";
// asharca.github.io/ui/components/blocks/selector
import { Check, ChevronDown, Search, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { SPRING_PRESS } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
export type SelectorOption = {
id: string;
name: string;
description?: string;
group?: string;
metadata?: string;
keywords?: string[];
icon?: ReactNode;
preview?: ReactNode;
details?: ReactNode;
disabled?: boolean;
};
/** Optional Tailwind overrides, merged after each slot's defaults. */
export type SelectorClassNames = {
trigger?: string;
content?: string;
list?: string;
option?: string;
icon?: string;
search?: string;
details?: string;
};
type SharedSelectorProps = {
options: SelectorOption[];
defaultOpen?: boolean;
placeholder?: string;
searchPlaceholder?: string;
label?: string;
triggerVariant?: "pill" | "field" | "workspace";
layout?: "list" | "grid";
action?: { label: string; onSelect: () => void };
className?: string;
/** Style individual parts independently; className styles the outer wrapper. */
classNames?: SelectorClassNames;
};
export type SelectorProps = SharedSelectorProps & (
| {
multiple?: false;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
| {
multiple: true;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
}
);
const SCROLLBAR_CLASS =
"[scrollbar-width:thin] [scrollbar-gutter:stable] [scrollbar-color:color-mix(in_oklab,var(--foreground)_18%,transparent)_transparent] hover:[scrollbar-color:color-mix(in_oklab,var(--foreground)_30%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb:hover]:bg-foreground/30";
export function Selector(props: SelectorProps) {
const {
options,
defaultOpen = false,
placeholder = "Select an option",
searchPlaceholder = "Search options",
label = "Options",
triggerVariant = "field",
layout = "list",
action,
className,
classNames,
} = props;
const uid = useId();
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState<string | string[]>(() =>
props.defaultValue ?? (props.multiple ? [] : options.find((option) => !option.disabled)?.id ?? ""),
);
const currentValue = props.value ?? internalValue;
const latestValue = useRef(currentValue);
useLayoutEffect(() => {
latestValue.current = currentValue;
});
const selectedIds = new Set(Array.isArray(currentValue) ? currentValue : [currentValue]);
const selectedOptions = options.filter((option) => !option.disabled && selectedIds.has(option.id));
const selected = selectedOptions[0];
const [open, setOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const detailRef = useRef<HTMLElement>(null);
const detailAnchor = useRef<HTMLElement | null>(null);
const detailTimer = useRef<number | undefined>(undefined);
const touchInteraction = useRef(false);
const [detailId, setDetailId] = useState<string | null>(null);
const [detailPosition, setDetailPosition] = useState({ top: 12, left: 12 });
const groups = useMemo(() => {
const result = new Map<string, SelectorOption[]>();
const search = query.trim().toLowerCase();
for (const option of options) {
if (![option.name, option.description, option.group, option.metadata, ...(option.keywords ?? [])]
.filter(Boolean).join(" ").toLowerCase().includes(search)) continue;
const group = option.group ?? "";
const items = result.get(group) ?? [];
items.push(option);
result.set(group, items);
}
return Array.from(result, ([label, items]) => ({ label, items }));
}, [options, query]);
const rows = useMemo(() => groups.flatMap((group) => group.items).filter((option) => !option.disabled), [groups]);
const { activeIndex, moveTo, moveActive } = useRowCursor(rows, query);
const active = rows[activeIndex];
const detail = open ? rows.find((option) => option.id === detailId && option.details != null) : undefined;
const clearDetailTimer = () => {
window.clearTimeout(detailTimer.current);
detailTimer.current = undefined;
};
const hideDetail = () => {
clearDetailTimer();
setDetailId(null);
};
const scheduleDetailClose = () => {
clearDetailTimer();
detailTimer.current = window.setTimeout(() => setDetailId(null), 100);
};
const showDetail = (option: SelectorOption, anchor: HTMLElement) => {
clearDetailTimer();
if (option.disabled || option.details == null || touchInteraction.current) {
setDetailId(null);
return;
}
if (detailId === option.id) return;
setDetailId(null);
detailTimer.current = window.setTimeout(() => {
detailAnchor.current = anchor;
setDetailId(option.id);
}, 1500);
};
useEffect(() => {
if (!open || rows.length === 0) window.clearTimeout(detailTimer.current);
return () => window.clearTimeout(detailTimer.current);
}, [open, rows]);
useLayoutEffect(() => {
if (!detail) return;
const place = () => {
const rect = detailAnchor.current?.getBoundingClientRect();
const card = detailRef.current;
if (!rect || !card) return;
const rightSpace = window.innerWidth - rect.right - 20;
const leftSpace = rect.left - 20;
const left = rightSpace >= card.offsetWidth || rightSpace >= leftSpace
? rect.right + 8 : rect.left - card.offsetWidth - 8;
setDetailPosition({
left: Math.max(12, Math.min(left, window.innerWidth - card.offsetWidth - 12)),
top: Math.max(12, Math.min(rect.top, window.innerHeight - card.offsetHeight - 12)),
});
};
place();
const observer = new ResizeObserver(place);
if (detailRef.current) observer.observe(detailRef.current);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [detail]);
useOnOpen(open, () => {
setQuery("");
setDetailId(null);
moveTo(selected?.id ?? null);
});
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
});
return () => cancelAnimationFrame(frame);
}, [open, activeIndex]);
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) {
hideDetail();
setQuery("");
}
};
const close = () => {
changeOpen(false);
triggerRef.current?.focus();
};
const select = (option: SelectorOption) => {
if (option.disabled) return;
if (props.multiple) {
const liveValue = props.value ?? latestValue.current;
const liveIds = Array.isArray(liveValue) ? liveValue : [];
const enabledIds = liveIds.filter((id) => options.some((item) => item.id === id && !item.disabled));
const next = enabledIds.includes(option.id)
? enabledIds.filter((id) => id !== option.id)
: [...enabledIds, option.id];
if (props.value === undefined) {
latestValue.current = next;
setInternalValue(next);
}
props.onValueChange?.(next);
hideDetail();
} else {
if (props.value === undefined) setInternalValue(option.id);
props.onValueChange?.(option.id);
close();
}
};
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.nativeEvent.isComposing) return;
touchInteraction.current = false;
if (event.key !== "Tab") hideDetail();
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.target !== inputRef.current &&
!(event.target instanceof HTMLElement && event.target.getAttribute("role") === "option")) {
return;
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
for (let step = 0; step < 12; step++) moveActive(event.key === "PageDown" ? 1 : -1);
} else if (event.key === "Enter" && active) {
event.preventDefault();
select(active);
}
};
return (
<MorphPopover open={open} onOpenChange={changeOpen} className={cn("max-w-full", triggerVariant === "pill" ? "w-auto" : "w-64", className)}>
<div className={cn("w-full border border-border bg-background shadow-sm", triggerVariant === "pill" ? "rounded-3xl" : "rounded-xl")}>
<MorphPopoverTrigger>
<button
type="button"
ref={triggerRef}
className={cn("flex min-h-11 w-full items-center gap-2 rounded-[inherit] px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring", classNames?.trigger)}
>
{selected?.icon != null ? <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted", classNames?.icon)}>{selected.icon}</span> : null}
<span className="min-w-0 flex-1">
<span className={cn("block truncate font-medium", !selected && "text-muted-foreground")}>
{props.multiple ? (selectedOptions.length ? `${selectedOptions.length} selected` : placeholder) : selected?.name ?? placeholder}
</span>
{triggerVariant === "workspace" && selected?.description ? <span className="block truncate text-xs text-muted-foreground">{selected.description}</span> : null}
</span>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
>
<ChevronDown className="size-4" />
</motion.span>
</button>
</MorphPopoverTrigger>
{props.multiple && selectedOptions.length ? (
<div className="flex flex-wrap gap-1.5 px-2.5 pb-2.5">
{selectedOptions.map((option) => (
<span key={option.id} className="inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pr-0.5 pl-2 text-xs">
<span className="truncate">{option.name}</span>
<button type="button" aria-label={`Remove ${option.name}`} onClick={() => { select(option); triggerRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-3" /></button>
</span>
))}
</div>
) : null}
</div>
<MorphPopoverContent align="start" sideOffset={6} radius={16} onOpenAutoFocus={() => inputRef.current?.focus()} className={cn("flex w-[22rem] max-w-[calc(100vw-24px)] flex-col bg-background text-sm text-foreground", classNames?.content)}>
<div className="shrink-0 p-2 pb-1">
<div className={cn("flex h-10 items-center gap-2 rounded-lg bg-muted/60 px-3", classNames?.search)}>
<Search aria-hidden className="size-4 shrink-0 text-muted-foreground" />
<input ref={inputRef} onKeyDown={onKeyDown} value={query} onChange={(event) => { hideDetail(); setQuery(event.target.value); }} role="combobox" aria-label={searchPlaceholder} aria-expanded="true" aria-autocomplete="list" aria-controls={`${uid}-options`} aria-activedescendant={active ? `${uid}-option-${activeIndex}` : undefined} placeholder={searchPlaceholder} className="h-full min-w-0 flex-1 bg-transparent text-[inherit] outline-none placeholder:text-muted-foreground" />
{query ? <button type="button" aria-label="Clear search" onClick={() => { hideDetail(); setQuery(""); inputRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-4" /></button> : <kbd className="text-[10px] text-muted-foreground">ESC</kbd>}
</div>
</div>
<div ref={listRef} id={`${uid}-options`} role="listbox" aria-label={label} aria-multiselectable={props.multiple || undefined} onScroll={hideDetail} className={cn("max-h-[min(360px,calc(100dvh-176px))] min-h-0 overflow-y-auto overscroll-contain p-2", SCROLLBAR_CLASS, classNames?.list)}>
{groups.length ? groups.map((group) => (
<fieldset key={group.label} className="mb-2 min-w-0 border-0 p-0 last:mb-0">
{group.label ? <legend className="px-2 py-2 text-[11px] font-medium text-muted-foreground">{group.label}</legend> : null}
<div className={cn(layout === "grid" ? "grid grid-cols-2 gap-2" : "space-y-0.5")}>
{group.items.map((option) => {
const index = rows.indexOf(option);
const isActive = active?.id === option.id;
const isSelected = !option.disabled && selectedIds.has(option.id);
return (
<button key={option.id} id={index >= 0 ? `${uid}-option-${index}` : undefined} type="button" role="option" aria-selected={isSelected} aria-disabled={option.disabled || undefined} disabled={option.disabled} data-index={index >= 0 ? index : undefined} onKeyDown={onKeyDown} tabIndex={-1} aria-describedby={detail?.id === option.id ? `${uid}-details` : undefined}
onPointerEnter={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current || option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }}
onPointerDown={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current) hideDetail(); }} onPointerLeave={scheduleDetailClose}
onFocus={(event) => { if (option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }} onBlur={scheduleDetailClose} onClick={() => select(option)}
className={cn("relative flex min-h-12 w-full gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40", layout === "grid" ? "flex-col items-stretch border border-border" : "items-center", layout === "grid" && isSelected && "border-foreground", isActive && "bg-muted/70", classNames?.option)}>
{layout === "grid" ? <div className="flex aspect-[16/10] items-center justify-center overflow-hidden rounded-lg bg-muted/60 text-muted-foreground">{option.preview ?? option.icon ?? <span className="text-2xl font-medium">{option.name.slice(0, 1)}</span>}</div> : <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground", classNames?.icon)}>{option.icon ?? <span className="text-xs font-medium">{option.name.slice(0, 1)}</span>}</span>}
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{option.name}</span>{option.description ? <span className="block truncate text-xs text-muted-foreground">{option.description}</span> : null}</span>
{option.metadata ? <span className="shrink-0 text-[10px] text-muted-foreground">{option.metadata}</span> : null}
<span aria-hidden className={cn("flex size-4 shrink-0 items-center justify-center", props.multiple && "rounded border border-border", props.multiple && isSelected && "border-foreground bg-foreground text-background")}>{isSelected ? <Check className="size-3.5" /> : null}</span>
</span>
</button>
);
})}
</div>
</fieldset>
)) : <p className="px-3 py-8 text-center text-sm text-muted-foreground">No options found.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex flex-wrap gap-x-3 gap-y-1">{props.multiple ? <span>{selectedOptions.length} selected</span> : <><span><kbd>↑↓</kbd> Navigate</span><span><kbd>↵</kbd> Select</span></>}</span>
<div className="flex items-center gap-2">
{action ? <button type="button" onClick={() => { close(); action.onSelect(); }} className="rounded-md px-2 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring">{action.label}</button> : null}
{props.multiple ? <button type="button" onClick={close} className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background focus-visible:outline-2 focus-visible:outline-ring">Done</button> : null}
</div>
</div>
</MorphPopoverContent>
{detail ? createPortal(
<aside ref={detailRef} id={`${uid}-details`} aria-label="Option details" onKeyDown={onKeyDown} onPointerEnter={clearDetailTimer} onPointerLeave={scheduleDetailClose} onPointerDown={(event) => event.stopPropagation()} onFocus={clearDetailTimer} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) scheduleDetailClose(); }} className={cn("fixed z-[10000] max-h-[min(420px,70dvh)] w-[21rem] max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-background p-4 text-sm text-foreground shadow-xl", SCROLLBAR_CLASS, classNames?.details)} style={{ ...detailPosition, backgroundColor: "var(--background)" }}>{detail.details}</aside>, document.body,
) : null}
</MorphPopover>
);
}
Resources · Grid
selector.tsxSet layout="grid" and provide preview content to choose templates, media or other visual resources. Search and selection work as in the list; keyboard arrows move through the displayed options. The preview uses lightweight layout thumbnails rather than image dependencies.
Start with a template
Browse the previews or search for a layout that fits your next project.
Selected template: Dashboard.
"use client";
import { Bot, Brain, Building2, Circle, Sparkles } from "lucide-react";
import { useState } from "react";
import { Selector } from "@/components/motion/selector";
import type { SelectorOption } from "@/components/motion/selector";
const MODELS: SelectorOption[] = [
{
id: "claude-sonnet",
name: "Claude Sonnet",
group: "Anthropic",
description: "Balanced reasoning and everyday coding",
metadata: "200K",
keywords: ["reasoning", "vision", "tools"],
icon: <Brain className="size-4"/>,
details: (<div className="space-y-2">
<p className="font-medium">Claude Sonnet</p>
<p className="text-sm text-muted-foreground">
A balanced choice for writing, analysis, and everyday coding. Supports
images and tools with a 200K-token context window.
</p>
</div>),
},
{
id: "claude-opus",
name: "Claude Opus",
group: "Anthropic",
description: "Deep reasoning for complex work",
metadata: "200K",
keywords: ["reasoning", "analysis"],
icon: <Brain className="size-4"/>,
},
{
id: "gpt-4o",
name: "GPT-4o",
group: "OpenAI",
description: "Fast, multimodal conversations",
metadata: "128K",
keywords: ["vision", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gpt-4.1",
name: "GPT-4.1",
group: "OpenAI",
description: "Precise instructions and a larger context",
metadata: "1M",
keywords: ["coding", "tools"],
icon: <Sparkles className="size-4"/>,
},
{
id: "gemini-pro",
name: "Gemini Pro",
group: "Google",
description: "Reason across documents, images, and code",
metadata: "1M",
keywords: ["vision", "reasoning"],
icon: <Bot className="size-4"/>,
},
];
export function SelectorPreview() {
const [model, setModel] = useState("claude-sonnet");
return (<div className="w-full max-w-md space-y-4">
<p className="font-medium text-sm">Choose your model</p>
<Selector options={MODELS} value={model} onValueChange={setModel} triggerVariant="pill" label="Models" searchPlaceholder="Search models…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Using {MODELS.find((option) => option.id === model)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Search by model, provider, or capability. Hover or focus Claude Sonnet
for details. Customize the trigger and panel with classNames.
</p>
</div>);
}
const PEOPLE: SelectorOption[] = [
{ id: "ava", name: "Ava Thompson", description: "ava@studio.co", metadata: "Design", group: "Your team" },
{ id: "noah", name: "Noah Williams", description: "noah@studio.co", metadata: "Engineering", group: "Your team" },
{ id: "mia", name: "Mia Chen", description: "mia@studio.co", metadata: "Product", group: "Your team" },
{ id: "leo", name: "Leo Martin", description: "leo@partner.co", metadata: "Guest", group: "Collaborators" },
].map((person) => ({
...person,
icon: (<span className="flex size-7 items-center justify-center rounded-full border bg-muted text-[10px] font-medium">
{person.name.split(" ").map((part) => part[0]).join("")}
</span>),
}));
export function SelectorPeopleSinglePreview() {
const [person, setPerson] = useState("");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Choose an assignee</p>
<p className="text-xs text-muted-foreground">
Search by name or email and choose one person responsible for the task.
</p>
</div>
<Selector options={PEOPLE} value={person} onValueChange={setPerson} placeholder="Assign to someone" searchPlaceholder="Search people…" label="Assignee"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{person ? `Assigned to ${PEOPLE.find((option) => option.id === person)?.name}.` : "No assignee yet."}
</p>
</div>);
}
export function SelectorPeoplePreview() {
const [people, setPeople] = useState<string[]>(["ava", "mia"]);
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Share with your team</p>
<p className="text-xs text-muted-foreground">
Search names or emails, then select everyone who needs access.
</p>
</div>
<Selector multiple options={PEOPLE} value={people} onValueChange={setPeople} triggerVariant="field" placeholder="Add people" searchPlaceholder="Search people…" label="People"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
{people.length === 0
? "Only you have access."
: `Shared with ${people.length} ${people.length === 1 ? "person" : "people"}.`}
</p>
<p className="text-xs text-muted-foreground">
Remove a chip to revoke access, or choose Done when finished.
</p>
</div>);
}
const WORKSPACES: SelectorOption[] = [
{
id: "studio",
name: "Studio",
description: "Pro plan · 12 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "engineering",
name: "Engineering",
description: "Pro plan · 8 members",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
{
id: "personal",
name: "Personal",
description: "Free plan · Just you",
group: "Personal workspaces",
icon: <Circle className="size-4"/>,
},
];
export function SelectorWorkspacePreview() {
const [options, setOptions] = useState(WORKSPACES);
const [workspace, setWorkspace] = useState("studio");
function createWorkspace() {
const number = options.length - WORKSPACES.length + 1;
const id = `new-workspace-${number}`;
setOptions([
...options,
{
id,
name: `New workspace ${number}`,
description: "Free plan · Just you",
group: "Team workspaces",
icon: <Building2 className="size-4"/>,
},
]);
setWorkspace(id);
}
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Your work, in one place</p>
<p className="text-xs text-muted-foreground">
Switch between team and personal workspaces.
</p>
</div>
<Selector options={options} value={workspace} onValueChange={setWorkspace} triggerVariant="workspace" searchPlaceholder="Find a workspace…" label="Workspaces" action={{ label: "Create workspace", onSelect: createWorkspace }}/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Current workspace: {options.find((option) => option.id === workspace)?.name}.
</p>
<p className="text-xs text-muted-foreground">
Create workspace adds a new workspace and switches to it immediately.
New workspaces live in this demo until the page is refreshed.
</p>
</div>);
}
function TemplateThumbnail({ variant }: {
variant: "dashboard" | "editorial" | "board" | "portfolio";
}) {
return (<div aria-hidden="true" className="flex aspect-[16/10] w-full overflow-hidden rounded-lg border bg-background p-2 text-foreground">
{variant === "dashboard" ? (<>
<div className="mr-2 w-1/5 space-y-2 border-r pr-2">
<div className="h-2 w-3 rounded-sm bg-foreground/60"/>
{[0, 1, 2, 3].map((item) => <div key={item} className="h-1 rounded bg-foreground/15"/>)}
</div>
<div className="flex flex-1 flex-col gap-2">
<div className="h-1.5 w-1/2 rounded bg-foreground/40"/>
<div className="grid grid-cols-3 gap-1">
{[0, 1, 2].map((item) => <div key={item} className="h-5 rounded border bg-foreground/5"/>)}
</div>
<div className="flex flex-1 items-end gap-1 rounded border p-1">
{[30, 55, 40, 75, 60, 90, 70].map((height) => <div key={height} className="flex-1 rounded-t-sm bg-foreground/25" style={{ height: `${height}%` }}/>)}
</div>
</div>
</>) : variant === "editorial" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1 w-full border-b"/>
<div className="mx-auto h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="mx-auto h-1 w-1/3 rounded bg-foreground/20"/>
<div className="flex flex-1 gap-2">
<div className="w-1/2 rounded bg-foreground/10"/>
<div className="flex-1 space-y-1.5 pt-1">
{[0, 1, 2, 3, 4].map((item) => <div key={item} className="h-1 rounded bg-foreground/15 last:w-2/3"/>)}
</div>
</div>
</div>) : variant === "board" ? (<div className="flex w-full flex-col gap-2">
<div className="h-1.5 w-1/3 rounded bg-foreground/50"/>
<div className="grid flex-1 grid-cols-3 gap-1.5">
{[2, 3, 1].map((count) => (<div key={count} className="space-y-1 rounded bg-foreground/5 p-1">
<div className="mb-2 h-1 w-2/3 rounded bg-foreground/25"/>
{["first", "second", "third"].slice(0, count).map((card) => <div key={card} className="h-4 rounded-sm border bg-background"/>)}
</div>))}
</div>
</div>) : (<div className="flex w-full flex-col gap-2">
<div className="flex justify-between"><div className="h-1.5 w-6 rounded bg-foreground/50"/><div className="h-1 w-8 rounded bg-foreground/15"/></div>
<div className="mt-1 h-2 w-2/3 rounded-sm bg-foreground/60"/>
<div className="h-1 w-1/2 rounded bg-foreground/20"/>
<div className="grid flex-1 grid-cols-2 gap-2"><div className="rounded bg-foreground/10"/><div className="rounded bg-foreground/20"/></div>
</div>)}
</div>);
}
const RESOURCES: SelectorOption[] = [
{ id: "dashboard", name: "Dashboard", description: "Metrics at a glance", metadata: "12 blocks", preview: <TemplateThumbnail variant="dashboard"/>, keywords: ["analytics", "charts"] },
{ id: "editorial", name: "Editorial", description: "Stories worth reading", metadata: "8 blocks", preview: <TemplateThumbnail variant="editorial"/>, keywords: ["blog", "writing"] },
{ id: "board", name: "Project board", description: "A little more organized", metadata: "6 blocks", preview: <TemplateThumbnail variant="board"/>, keywords: ["kanban", "tasks"] },
{ id: "portfolio", name: "Portfolio", description: "Make your work the focus", metadata: "10 blocks", preview: <TemplateThumbnail variant="portfolio"/>, keywords: ["gallery", "personal"] },
];
export function SelectorResourcesPreview() {
const [resource, setResource] = useState("dashboard");
return (<div className="w-full max-w-md space-y-4">
<div className="space-y-1">
<p className="font-medium text-sm">Start with a template</p>
<p className="text-xs text-muted-foreground">
Browse the previews or search for a layout that fits your next project.
</p>
</div>
<Selector options={RESOURCES} value={resource} onValueChange={setResource} layout="grid" triggerVariant="field" classNames={{ content: "w-[30rem]", list: "max-h-[26rem]" }} label="Templates" searchPlaceholder="Search templates…"/>
<p aria-live="polite" className="text-xs text-muted-foreground">
Selected template: {RESOURCES.find((option) => option.id === resource)?.name}.
</p>
</div>);
}
"use client";
// asharca.github.io/ui/components/blocks/selector
import { Check, ChevronDown, Search, X } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { SPRING_PRESS } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
export type SelectorOption = {
id: string;
name: string;
description?: string;
group?: string;
metadata?: string;
keywords?: string[];
icon?: ReactNode;
preview?: ReactNode;
details?: ReactNode;
disabled?: boolean;
};
/** Optional Tailwind overrides, merged after each slot's defaults. */
export type SelectorClassNames = {
trigger?: string;
content?: string;
list?: string;
option?: string;
icon?: string;
search?: string;
details?: string;
};
type SharedSelectorProps = {
options: SelectorOption[];
defaultOpen?: boolean;
placeholder?: string;
searchPlaceholder?: string;
label?: string;
triggerVariant?: "pill" | "field" | "workspace";
layout?: "list" | "grid";
action?: { label: string; onSelect: () => void };
className?: string;
/** Style individual parts independently; className styles the outer wrapper. */
classNames?: SelectorClassNames;
};
export type SelectorProps = SharedSelectorProps & (
| {
multiple?: false;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
}
| {
multiple: true;
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
}
);
const SCROLLBAR_CLASS =
"[scrollbar-width:thin] [scrollbar-gutter:stable] [scrollbar-color:color-mix(in_oklab,var(--foreground)_18%,transparent)_transparent] hover:[scrollbar-color:color-mix(in_oklab,var(--foreground)_30%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb:hover]:bg-foreground/30";
export function Selector(props: SelectorProps) {
const {
options,
defaultOpen = false,
placeholder = "Select an option",
searchPlaceholder = "Search options",
label = "Options",
triggerVariant = "field",
layout = "list",
action,
className,
classNames,
} = props;
const uid = useId();
const reduce = useReducedMotion();
const [internalValue, setInternalValue] = useState<string | string[]>(() =>
props.defaultValue ?? (props.multiple ? [] : options.find((option) => !option.disabled)?.id ?? ""),
);
const currentValue = props.value ?? internalValue;
const latestValue = useRef(currentValue);
useLayoutEffect(() => {
latestValue.current = currentValue;
});
const selectedIds = new Set(Array.isArray(currentValue) ? currentValue : [currentValue]);
const selectedOptions = options.filter((option) => !option.disabled && selectedIds.has(option.id));
const selected = selectedOptions[0];
const [open, setOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const listRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const detailRef = useRef<HTMLElement>(null);
const detailAnchor = useRef<HTMLElement | null>(null);
const detailTimer = useRef<number | undefined>(undefined);
const touchInteraction = useRef(false);
const [detailId, setDetailId] = useState<string | null>(null);
const [detailPosition, setDetailPosition] = useState({ top: 12, left: 12 });
const groups = useMemo(() => {
const result = new Map<string, SelectorOption[]>();
const search = query.trim().toLowerCase();
for (const option of options) {
if (![option.name, option.description, option.group, option.metadata, ...(option.keywords ?? [])]
.filter(Boolean).join(" ").toLowerCase().includes(search)) continue;
const group = option.group ?? "";
const items = result.get(group) ?? [];
items.push(option);
result.set(group, items);
}
return Array.from(result, ([label, items]) => ({ label, items }));
}, [options, query]);
const rows = useMemo(() => groups.flatMap((group) => group.items).filter((option) => !option.disabled), [groups]);
const { activeIndex, moveTo, moveActive } = useRowCursor(rows, query);
const active = rows[activeIndex];
const detail = open ? rows.find((option) => option.id === detailId && option.details != null) : undefined;
const clearDetailTimer = () => {
window.clearTimeout(detailTimer.current);
detailTimer.current = undefined;
};
const hideDetail = () => {
clearDetailTimer();
setDetailId(null);
};
const scheduleDetailClose = () => {
clearDetailTimer();
detailTimer.current = window.setTimeout(() => setDetailId(null), 100);
};
const showDetail = (option: SelectorOption, anchor: HTMLElement) => {
clearDetailTimer();
if (option.disabled || option.details == null || touchInteraction.current) {
setDetailId(null);
return;
}
if (detailId === option.id) return;
setDetailId(null);
detailTimer.current = window.setTimeout(() => {
detailAnchor.current = anchor;
setDetailId(option.id);
}, 1500);
};
useEffect(() => {
if (!open || rows.length === 0) window.clearTimeout(detailTimer.current);
return () => window.clearTimeout(detailTimer.current);
}, [open, rows]);
useLayoutEffect(() => {
if (!detail) return;
const place = () => {
const rect = detailAnchor.current?.getBoundingClientRect();
const card = detailRef.current;
if (!rect || !card) return;
const rightSpace = window.innerWidth - rect.right - 20;
const leftSpace = rect.left - 20;
const left = rightSpace >= card.offsetWidth || rightSpace >= leftSpace
? rect.right + 8 : rect.left - card.offsetWidth - 8;
setDetailPosition({
left: Math.max(12, Math.min(left, window.innerWidth - card.offsetWidth - 12)),
top: Math.max(12, Math.min(rect.top, window.innerHeight - card.offsetHeight - 12)),
});
};
place();
const observer = new ResizeObserver(place);
if (detailRef.current) observer.observe(detailRef.current);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [detail]);
useOnOpen(open, () => {
setQuery("");
setDetailId(null);
moveTo(selected?.id ?? null);
});
useEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
});
return () => cancelAnimationFrame(frame);
}, [open, activeIndex]);
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) {
hideDetail();
setQuery("");
}
};
const close = () => {
changeOpen(false);
triggerRef.current?.focus();
};
const select = (option: SelectorOption) => {
if (option.disabled) return;
if (props.multiple) {
const liveValue = props.value ?? latestValue.current;
const liveIds = Array.isArray(liveValue) ? liveValue : [];
const enabledIds = liveIds.filter((id) => options.some((item) => item.id === id && !item.disabled));
const next = enabledIds.includes(option.id)
? enabledIds.filter((id) => id !== option.id)
: [...enabledIds, option.id];
if (props.value === undefined) {
latestValue.current = next;
setInternalValue(next);
}
props.onValueChange?.(next);
hideDetail();
} else {
if (props.value === undefined) setInternalValue(option.id);
props.onValueChange?.(option.id);
close();
}
};
const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.nativeEvent.isComposing) return;
touchInteraction.current = false;
if (event.key !== "Tab") hideDetail();
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.target !== inputRef.current &&
!(event.target instanceof HTMLElement && event.target.getAttribute("role") === "option")) {
return;
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "PageDown" || event.key === "PageUp") {
event.preventDefault();
for (let step = 0; step < 12; step++) moveActive(event.key === "PageDown" ? 1 : -1);
} else if (event.key === "Enter" && active) {
event.preventDefault();
select(active);
}
};
return (
<MorphPopover open={open} onOpenChange={changeOpen} className={cn("max-w-full", triggerVariant === "pill" ? "w-auto" : "w-64", className)}>
<div className={cn("w-full border border-border bg-background shadow-sm", triggerVariant === "pill" ? "rounded-3xl" : "rounded-xl")}>
<MorphPopoverTrigger>
<button
type="button"
ref={triggerRef}
className={cn("flex min-h-11 w-full items-center gap-2 rounded-[inherit] px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring", classNames?.trigger)}
>
{selected?.icon != null ? <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted", classNames?.icon)}>{selected.icon}</span> : null}
<span className="min-w-0 flex-1">
<span className={cn("block truncate font-medium", !selected && "text-muted-foreground")}>
{props.multiple ? (selectedOptions.length ? `${selectedOptions.length} selected` : placeholder) : selected?.name ?? placeholder}
</span>
{triggerVariant === "workspace" && selected?.description ? <span className="block truncate text-xs text-muted-foreground">{selected.description}</span> : null}
</span>
<motion.span
aria-hidden
initial={false}
animate={{ rotate: open ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_PRESS}
className="flex size-4 shrink-0 items-center justify-center text-muted-foreground"
>
<ChevronDown className="size-4" />
</motion.span>
</button>
</MorphPopoverTrigger>
{props.multiple && selectedOptions.length ? (
<div className="flex flex-wrap gap-1.5 px-2.5 pb-2.5">
{selectedOptions.map((option) => (
<span key={option.id} className="inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pr-0.5 pl-2 text-xs">
<span className="truncate">{option.name}</span>
<button type="button" aria-label={`Remove ${option.name}`} onClick={() => { select(option); triggerRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-3" /></button>
</span>
))}
</div>
) : null}
</div>
<MorphPopoverContent align="start" sideOffset={6} radius={16} onOpenAutoFocus={() => inputRef.current?.focus()} className={cn("flex w-[22rem] max-w-[calc(100vw-24px)] flex-col bg-background text-sm text-foreground", classNames?.content)}>
<div className="shrink-0 p-2 pb-1">
<div className={cn("flex h-10 items-center gap-2 rounded-lg bg-muted/60 px-3", classNames?.search)}>
<Search aria-hidden className="size-4 shrink-0 text-muted-foreground" />
<input ref={inputRef} onKeyDown={onKeyDown} value={query} onChange={(event) => { hideDetail(); setQuery(event.target.value); }} role="combobox" aria-label={searchPlaceholder} aria-expanded="true" aria-autocomplete="list" aria-controls={`${uid}-options`} aria-activedescendant={active ? `${uid}-option-${activeIndex}` : undefined} placeholder={searchPlaceholder} className="h-full min-w-0 flex-1 bg-transparent text-[inherit] outline-none placeholder:text-muted-foreground" />
{query ? <button type="button" aria-label="Clear search" onClick={() => { hideDetail(); setQuery(""); inputRef.current?.focus(); }} className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"><X aria-hidden className="size-4" /></button> : <kbd className="text-[10px] text-muted-foreground">ESC</kbd>}
</div>
</div>
<div ref={listRef} id={`${uid}-options`} role="listbox" aria-label={label} aria-multiselectable={props.multiple || undefined} onScroll={hideDetail} className={cn("max-h-[min(360px,calc(100dvh-176px))] min-h-0 overflow-y-auto overscroll-contain p-2", SCROLLBAR_CLASS, classNames?.list)}>
{groups.length ? groups.map((group) => (
<fieldset key={group.label} className="mb-2 min-w-0 border-0 p-0 last:mb-0">
{group.label ? <legend className="px-2 py-2 text-[11px] font-medium text-muted-foreground">{group.label}</legend> : null}
<div className={cn(layout === "grid" ? "grid grid-cols-2 gap-2" : "space-y-0.5")}>
{group.items.map((option) => {
const index = rows.indexOf(option);
const isActive = active?.id === option.id;
const isSelected = !option.disabled && selectedIds.has(option.id);
return (
<button key={option.id} id={index >= 0 ? `${uid}-option-${index}` : undefined} type="button" role="option" aria-selected={isSelected} aria-disabled={option.disabled || undefined} disabled={option.disabled} data-index={index >= 0 ? index : undefined} onKeyDown={onKeyDown} tabIndex={-1} aria-describedby={detail?.id === option.id ? `${uid}-details` : undefined}
onPointerEnter={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current || option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }}
onPointerDown={(event) => { touchInteraction.current = event.pointerType === "touch"; if (touchInteraction.current) hideDetail(); }} onPointerLeave={scheduleDetailClose}
onFocus={(event) => { if (option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }} onBlur={scheduleDetailClose} onClick={() => select(option)}
className={cn("relative flex min-h-12 w-full gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40", layout === "grid" ? "flex-col items-stretch border border-border" : "items-center", layout === "grid" && isSelected && "border-foreground", isActive && "bg-muted/70", classNames?.option)}>
{layout === "grid" ? <div className="flex aspect-[16/10] items-center justify-center overflow-hidden rounded-lg bg-muted/60 text-muted-foreground">{option.preview ?? option.icon ?? <span className="text-2xl font-medium">{option.name.slice(0, 1)}</span>}</div> : <span aria-hidden className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground", classNames?.icon)}>{option.icon ?? <span className="text-xs font-medium">{option.name.slice(0, 1)}</span>}</span>}
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{option.name}</span>{option.description ? <span className="block truncate text-xs text-muted-foreground">{option.description}</span> : null}</span>
{option.metadata ? <span className="shrink-0 text-[10px] text-muted-foreground">{option.metadata}</span> : null}
<span aria-hidden className={cn("flex size-4 shrink-0 items-center justify-center", props.multiple && "rounded border border-border", props.multiple && isSelected && "border-foreground bg-foreground text-background")}>{isSelected ? <Check className="size-3.5" /> : null}</span>
</span>
</button>
);
})}
</div>
</fieldset>
)) : <p className="px-3 py-8 text-center text-sm text-muted-foreground">No options found.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex flex-wrap gap-x-3 gap-y-1">{props.multiple ? <span>{selectedOptions.length} selected</span> : <><span><kbd>↑↓</kbd> Navigate</span><span><kbd>↵</kbd> Select</span></>}</span>
<div className="flex items-center gap-2">
{action ? <button type="button" onClick={() => { close(); action.onSelect(); }} className="rounded-md px-2 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring">{action.label}</button> : null}
{props.multiple ? <button type="button" onClick={close} className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background focus-visible:outline-2 focus-visible:outline-ring">Done</button> : null}
</div>
</div>
</MorphPopoverContent>
{detail ? createPortal(
<aside ref={detailRef} id={`${uid}-details`} aria-label="Option details" onKeyDown={onKeyDown} onPointerEnter={clearDetailTimer} onPointerLeave={scheduleDetailClose} onPointerDown={(event) => event.stopPropagation()} onFocus={clearDetailTimer} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) scheduleDetailClose(); }} className={cn("fixed z-[10000] max-h-[min(420px,70dvh)] w-[21rem] max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-background p-4 text-sm text-foreground shadow-xl", SCROLLBAR_CLASS, classNames?.details)} style={{ ...detailPosition, backgroundColor: "var(--background)" }}>{detail.details}</aside>, document.body,
) : null}
</MorphPopover>
);
}
API Reference
optionsSelectorOption[]—defaultOpen?boolean—placeholder?string—searchPlaceholder?string—label?string—triggerVariant?"pill" | "field" | "workspace"—layout?"grid" | "list"—action?{ label: string; onSelect: () => void; }—className?string—classNames?SelectorClassNamesStyle individual parts independently; className styles the outer wrapper.
—multiple?boolean—value?string | string[]—defaultValue?string | string[]—onValueChange?((value: string) => void) | ((value: string[]) => void)—Related components
Morphing Search
Search field or compact icon that morphs into a glass results surface with keyboard navigation and optional result metadata.
Workspace Tab Bar
Surface-connected tabs with pinning, closing, drag and keyboard reordering, and host-owned window actions.
Morphing Tabs
Reorderable tabs whose selected item grows into a white content surface, with the active shape gliding as tabs move and a shared morph between rooms.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated