Select
NewComposable select primitives whose panel bouncily unfolds out of the trigger and separates, plus a Morph variant where the trigger grows into the panel via shared layout.
Select
select.tsxComposable primitives (Select, SelectTrigger, SelectValue, SelectContent, SelectItem); the panel pinches off the trigger and separates, with staggered items. Position-aware (opens upward when needed).
TSXcomponents/previews/motion/select.preview.tsx
"use client";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/motion/select";
export function SelectPreview() {
const [value, setValue] = useState("next");
return (
<div className="w-56">
<Select value={value} onValueChange={setValue}>
<SelectTrigger>
<SelectValue placeholder="Pick a framework" />
</SelectTrigger>
<SelectContent>
<SelectItem value="next">Next.js</SelectItem>
<SelectItem value="remix">Remix</SelectItem>
<SelectItem value="astro">Astro</SelectItem>
<SelectItem value="vite">Vite</SelectItem>
</SelectContent>
</Select>
</div>
);
}
TSXcomponents/motion/select.tsx
"use client";
// beui.dev/components/motion/select
import { Check, ChevronDown } from "lucide-react";
import {
motion,
type Transition,
useReducedMotion,
type Variants,
} from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
// Spring with bounce powers the unfold/separation; per-property timings in the
// content choreograph it (see SelectContent). Mirrors bouncy-accordion's feel.
const CHEVRON_TRANSITION: Transition = { type: "spring", duration: 0.4, bounce: 0.3 };
const LIST_VARIANTS: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } },
};
const ITEM_VARIANTS: Variants = {
hidden: { opacity: 0, y: -6, filter: "blur(3px)" },
show: { opacity: 1, y: 0, filter: "blur(0px)" },
};
type Placement = "bottom" | "top";
interface SelectContextValue {
value: string | undefined;
open: boolean;
setOpen: (open: boolean) => void;
select: (value: string) => void;
register: (value: string, label: string) => void;
unregister: (value: string) => void;
labelFor: (value: string | undefined) => string | undefined;
reduce: boolean;
triggerId: string;
listId: string;
disabled: boolean;
placement: Placement;
setPlacement: (p: Placement) => void;
}
const SelectContext = createContext<SelectContextValue | null>(null);
function useSelectContext(component: string) {
const ctx = useContext(SelectContext);
if (!ctx) throw new Error(`${component} must be used within <Select>`);
return ctx;
}
export interface SelectProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export function Select({
value,
defaultValue,
onValueChange,
disabled = false,
className,
children,
}: SelectProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState(defaultValue);
const [labels, setLabels] = useState<Map<string, string>>(new Map());
const [placement, setPlacement] = useState<Placement>("bottom");
const controlled = value !== undefined;
const current = controlled ? value : internal;
const select = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
setOpen(false);
},
[controlled, onValueChange],
);
const register = useCallback((v: string, label: string) => {
setLabels((m) => (m.get(v) === label ? m : new Map(m).set(v, label)));
}, []);
const unregister = useCallback((v: string) => {
setLabels((m) => {
if (!m.has(v)) return m;
const next = new Map(m);
next.delete(v);
return next;
});
}, []);
// close on outside pointer / escape
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
const onPointer = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open]);
const ctx = useMemo<SelectContextValue>(
() => ({
value: current,
open,
setOpen,
select,
register,
unregister,
labelFor: (v) => (v === undefined ? undefined : labels.get(v)),
reduce,
triggerId: `${baseId}-trigger`,
listId: `${baseId}-list`,
disabled,
placement,
setPlacement,
}),
[
current,
open,
select,
register,
unregister,
labels,
reduce,
baseId,
disabled,
placement,
],
);
return (
<SelectContext.Provider value={ctx}>
<div ref={rootRef} className={cn("relative", className)}>
{children}
</div>
</SelectContext.Provider>
);
}
export interface SelectTriggerProps {
className?: string;
children: ReactNode;
}
export function SelectTrigger({ className, children }: SelectTriggerProps) {
const ctx = useSelectContext("SelectTrigger");
const isTop = ctx.placement === "top";
// edge facing the panel flattens then rounds; the far edge stays rounded.
// All four corners are specified so none gets stranded when placement flips.
const kf = ctx.open ? [0, 0, 12] : [12, 0, 12];
const kfT: Transition = ctx.reduce
? { duration: 0 }
: ctx.open
? { duration: 0.6, times: [0, 0.4, 1], ease: EASE_OUT }
: { duration: 0.42, times: [0, 0.5, 1], ease: EASE_OUT };
const flatT: Transition = { duration: 0 };
return (
<motion.button
type="button"
id={ctx.triggerId}
disabled={ctx.disabled}
aria-haspopup="listbox"
aria-expanded={ctx.open}
aria-controls={ctx.listId}
onClick={() => ctx.setOpen(!ctx.open)}
// Gooey: the edge facing the panel snaps flat (panel attached) then rounds
// back once the panel pulls away — the two pinch apart.
initial={false}
animate={{
borderTopLeftRadius: isTop ? kf : 12,
borderTopRightRadius: isTop ? kf : 12,
borderBottomLeftRadius: isTop ? 12 : kf,
borderBottomRightRadius: isTop ? 12 : kf,
}}
transition={{
borderTopLeftRadius: isTop ? kfT : flatT,
borderTopRightRadius: isTop ? kfT : flatT,
borderBottomLeftRadius: isTop ? flatT : kfT,
borderBottomRightRadius: isTop ? flatT : kfT,
}}
className={cn(
"relative z-10 flex w-full items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors",
"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{children}
<motion.span
aria-hidden
animate={{ rotate: ctx.open ? 180 : 0 }}
transition={ctx.reduce ? { duration: 0 } : CHEVRON_TRANSITION}
className="text-muted-foreground"
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</motion.button>
);
}
export interface SelectValueProps {
placeholder?: string;
className?: string;
}
export function SelectValue({ placeholder, className }: SelectValueProps) {
const ctx = useSelectContext("SelectValue");
const label = ctx.labelFor(ctx.value);
return (
<span
className={cn(label ? "text-foreground" : "text-muted-foreground", className)}
>
{label ?? placeholder ?? "Select"}
</span>
);
}
export interface SelectContentProps {
className?: string;
children: ReactNode;
}
export function SelectContent({ className, children }: SelectContentProps) {
const ctx = useSelectContext("SelectContent");
const innerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
const open = ctx.open;
const { setPlacement } = ctx;
useLayoutEffect(() => {
const node = innerRef.current;
if (!node) return;
const measure = () => setHeight(node.offsetHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
});
// On open, flip upward when there isn't room below and there's more above.
useLayoutEffect(() => {
if (!open) return;
const trigger = document.getElementById(ctx.triggerId);
const node = innerRef.current;
if (!trigger || !node) return;
const rect = trigger.getBoundingClientRect();
const h = node.offsetHeight;
const below = window.innerHeight - rect.bottom;
const above = rect.top;
setPlacement(below < h + 16 && above > below ? "top" : "bottom");
}, [open, ctx.triggerId, setPlacement]);
// Specify EVERY corner + both margins each render. The near edge (facing the
// trigger) animates flat->round and the gap opens on that side; the far edge
// stays rounded and its margin pinned to 0. Setting all of them avoids a
// stranded square corner when the placement flips between opens.
const isTop = ctx.placement === "top";
const nearGap = open ? 8 : 0;
const nearRadius = open ? 12 : 0;
const gapT: Transition = open
? { type: "spring", duration: 0.6, bounce: 0.5, delay: 0.12 }
: { type: "spring", duration: 0.3, bounce: 0.1 };
const radiusT: Transition = open
? { duration: 0.3, ease: EASE_OUT, delay: 0.14 }
: { duration: 0.16, ease: EASE_OUT };
const instant: Transition = { duration: 0 };
// Items stay mounted (open just animates the panel) so each item's label
// registration persists — otherwise the trigger would fall back to the
// placeholder the moment the panel closes.
return (
<motion.div
id={ctx.listId}
role="listbox"
aria-labelledby={ctx.triggerId}
aria-hidden={!open}
initial={false}
animate={
ctx.reduce
? { opacity: open ? 1 : 0, height: open ? height : 0 }
: {
opacity: open ? 1 : 0,
height: open ? height : 0,
// gap opens on the side facing the trigger
marginTop: isTop ? 0 : nearGap,
marginBottom: isTop ? nearGap : 0,
// near corners go flat->round; far corners stay rounded
borderTopLeftRadius: isTop ? 12 : nearRadius,
borderTopRightRadius: isTop ? 12 : nearRadius,
borderBottomLeftRadius: isTop ? nearRadius : 12,
borderBottomRightRadius: isTop ? nearRadius : 12,
}
}
transition={
ctx.reduce
? { duration: 0.12 }
: {
opacity: open
? { duration: 0.18 }
: { duration: 0.16, delay: 0.12 },
height: open
? { type: "spring", duration: 0.42, bounce: 0.14 }
: { duration: 0.26, ease: EASE_OUT, delay: 0.14 },
marginTop: isTop ? instant : gapT,
marginBottom: isTop ? gapT : instant,
borderTopLeftRadius: isTop ? instant : radiusT,
borderTopRightRadius: isTop ? instant : radiusT,
borderBottomLeftRadius: isTop ? radiusT : instant,
borderBottomRightRadius: isTop ? radiusT : instant,
}
}
style={{
transformOrigin: isTop ? "bottom" : "top",
overflow: "hidden",
pointerEvents: open ? "auto" : "none",
}}
// flush against the trigger, then separates into its own rounded pill;
// sits above or below depending on available space
className={cn(
"absolute left-0 right-0 z-20 rounded-xl border border-border bg-background shadow-lg",
isTop ? "bottom-full" : "top-full",
className,
)}
>
<motion.div
ref={innerRef}
variants={ctx.reduce ? undefined : LIST_VARIANTS}
initial={false}
animate={open ? "show" : "hidden"}
className="p-1"
>
{children}
</motion.div>
</motion.div>
);
}
export interface SelectItemProps {
value: string;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export function SelectItem({
value,
disabled = false,
className,
children,
}: SelectItemProps) {
const ctx = useSelectContext("SelectItem");
const selected = ctx.value === value;
const label = typeof children === "string" ? children : value;
useLayoutEffect(() => {
ctx.register(value, label);
return () => ctx.unregister(value);
}, [ctx.register, ctx.unregister, value, label]);
return (
<motion.li variants={ctx.reduce ? undefined : ITEM_VARIANTS}>
<button
type="button"
role="option"
aria-selected={selected}
disabled={disabled}
onClick={() => ctx.select(value)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors",
selected
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{children}
{selected ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
</motion.li>
);
}
Install
$ bunx --bun shadcn add @beui/select
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/select.tsx
"use client";
// beui.dev/components/motion/select
import { Check, ChevronDown } from "lucide-react";
import {
motion,
type Transition,
useReducedMotion,
type Variants,
} from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
// Spring with bounce powers the unfold/separation; per-property timings in the
// content choreograph it (see SelectContent). Mirrors bouncy-accordion's feel.
const CHEVRON_TRANSITION: Transition = { type: "spring", duration: 0.4, bounce: 0.3 };
const LIST_VARIANTS: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.035, delayChildren: 0.05 } },
};
const ITEM_VARIANTS: Variants = {
hidden: { opacity: 0, y: -6, filter: "blur(3px)" },
show: { opacity: 1, y: 0, filter: "blur(0px)" },
};
type Placement = "bottom" | "top";
interface SelectContextValue {
value: string | undefined;
open: boolean;
setOpen: (open: boolean) => void;
select: (value: string) => void;
register: (value: string, label: string) => void;
unregister: (value: string) => void;
labelFor: (value: string | undefined) => string | undefined;
reduce: boolean;
triggerId: string;
listId: string;
disabled: boolean;
placement: Placement;
setPlacement: (p: Placement) => void;
}
const SelectContext = createContext<SelectContextValue | null>(null);
function useSelectContext(component: string) {
const ctx = useContext(SelectContext);
if (!ctx) throw new Error(`${component} must be used within <Select>`);
return ctx;
}
export interface SelectProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export function Select({
value,
defaultValue,
onValueChange,
disabled = false,
className,
children,
}: SelectProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState(defaultValue);
const [labels, setLabels] = useState<Map<string, string>>(new Map());
const [placement, setPlacement] = useState<Placement>("bottom");
const controlled = value !== undefined;
const current = controlled ? value : internal;
const select = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
setOpen(false);
},
[controlled, onValueChange],
);
const register = useCallback((v: string, label: string) => {
setLabels((m) => (m.get(v) === label ? m : new Map(m).set(v, label)));
}, []);
const unregister = useCallback((v: string) => {
setLabels((m) => {
if (!m.has(v)) return m;
const next = new Map(m);
next.delete(v);
return next;
});
}, []);
// close on outside pointer / escape
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
const onPointer = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open]);
const ctx = useMemo<SelectContextValue>(
() => ({
value: current,
open,
setOpen,
select,
register,
unregister,
labelFor: (v) => (v === undefined ? undefined : labels.get(v)),
reduce,
triggerId: `${baseId}-trigger`,
listId: `${baseId}-list`,
disabled,
placement,
setPlacement,
}),
[
current,
open,
select,
register,
unregister,
labels,
reduce,
baseId,
disabled,
placement,
],
);
return (
<SelectContext.Provider value={ctx}>
<div ref={rootRef} className={cn("relative", className)}>
{children}
</div>
</SelectContext.Provider>
);
}
export interface SelectTriggerProps {
className?: string;
children: ReactNode;
}
export function SelectTrigger({ className, children }: SelectTriggerProps) {
const ctx = useSelectContext("SelectTrigger");
const isTop = ctx.placement === "top";
// edge facing the panel flattens then rounds; the far edge stays rounded.
// All four corners are specified so none gets stranded when placement flips.
const kf = ctx.open ? [0, 0, 12] : [12, 0, 12];
const kfT: Transition = ctx.reduce
? { duration: 0 }
: ctx.open
? { duration: 0.6, times: [0, 0.4, 1], ease: EASE_OUT }
: { duration: 0.42, times: [0, 0.5, 1], ease: EASE_OUT };
const flatT: Transition = { duration: 0 };
return (
<motion.button
type="button"
id={ctx.triggerId}
disabled={ctx.disabled}
aria-haspopup="listbox"
aria-expanded={ctx.open}
aria-controls={ctx.listId}
onClick={() => ctx.setOpen(!ctx.open)}
// Gooey: the edge facing the panel snaps flat (panel attached) then rounds
// back once the panel pulls away — the two pinch apart.
initial={false}
animate={{
borderTopLeftRadius: isTop ? kf : 12,
borderTopRightRadius: isTop ? kf : 12,
borderBottomLeftRadius: isTop ? 12 : kf,
borderBottomRightRadius: isTop ? 12 : kf,
}}
transition={{
borderTopLeftRadius: isTop ? kfT : flatT,
borderTopRightRadius: isTop ? kfT : flatT,
borderBottomLeftRadius: isTop ? flatT : kfT,
borderBottomRightRadius: isTop ? flatT : kfT,
}}
className={cn(
"relative z-10 flex w-full items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors",
"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{children}
<motion.span
aria-hidden
animate={{ rotate: ctx.open ? 180 : 0 }}
transition={ctx.reduce ? { duration: 0 } : CHEVRON_TRANSITION}
className="text-muted-foreground"
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</motion.button>
);
}
export interface SelectValueProps {
placeholder?: string;
className?: string;
}
export function SelectValue({ placeholder, className }: SelectValueProps) {
const ctx = useSelectContext("SelectValue");
const label = ctx.labelFor(ctx.value);
return (
<span
className={cn(label ? "text-foreground" : "text-muted-foreground", className)}
>
{label ?? placeholder ?? "Select"}
</span>
);
}
export interface SelectContentProps {
className?: string;
children: ReactNode;
}
export function SelectContent({ className, children }: SelectContentProps) {
const ctx = useSelectContext("SelectContent");
const innerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
const open = ctx.open;
const { setPlacement } = ctx;
useLayoutEffect(() => {
const node = innerRef.current;
if (!node) return;
const measure = () => setHeight(node.offsetHeight);
measure();
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
});
// On open, flip upward when there isn't room below and there's more above.
useLayoutEffect(() => {
if (!open) return;
const trigger = document.getElementById(ctx.triggerId);
const node = innerRef.current;
if (!trigger || !node) return;
const rect = trigger.getBoundingClientRect();
const h = node.offsetHeight;
const below = window.innerHeight - rect.bottom;
const above = rect.top;
setPlacement(below < h + 16 && above > below ? "top" : "bottom");
}, [open, ctx.triggerId, setPlacement]);
// Specify EVERY corner + both margins each render. The near edge (facing the
// trigger) animates flat->round and the gap opens on that side; the far edge
// stays rounded and its margin pinned to 0. Setting all of them avoids a
// stranded square corner when the placement flips between opens.
const isTop = ctx.placement === "top";
const nearGap = open ? 8 : 0;
const nearRadius = open ? 12 : 0;
const gapT: Transition = open
? { type: "spring", duration: 0.6, bounce: 0.5, delay: 0.12 }
: { type: "spring", duration: 0.3, bounce: 0.1 };
const radiusT: Transition = open
? { duration: 0.3, ease: EASE_OUT, delay: 0.14 }
: { duration: 0.16, ease: EASE_OUT };
const instant: Transition = { duration: 0 };
// Items stay mounted (open just animates the panel) so each item's label
// registration persists — otherwise the trigger would fall back to the
// placeholder the moment the panel closes.
return (
<motion.div
id={ctx.listId}
role="listbox"
aria-labelledby={ctx.triggerId}
aria-hidden={!open}
initial={false}
animate={
ctx.reduce
? { opacity: open ? 1 : 0, height: open ? height : 0 }
: {
opacity: open ? 1 : 0,
height: open ? height : 0,
// gap opens on the side facing the trigger
marginTop: isTop ? 0 : nearGap,
marginBottom: isTop ? nearGap : 0,
// near corners go flat->round; far corners stay rounded
borderTopLeftRadius: isTop ? 12 : nearRadius,
borderTopRightRadius: isTop ? 12 : nearRadius,
borderBottomLeftRadius: isTop ? nearRadius : 12,
borderBottomRightRadius: isTop ? nearRadius : 12,
}
}
transition={
ctx.reduce
? { duration: 0.12 }
: {
opacity: open
? { duration: 0.18 }
: { duration: 0.16, delay: 0.12 },
height: open
? { type: "spring", duration: 0.42, bounce: 0.14 }
: { duration: 0.26, ease: EASE_OUT, delay: 0.14 },
marginTop: isTop ? instant : gapT,
marginBottom: isTop ? gapT : instant,
borderTopLeftRadius: isTop ? instant : radiusT,
borderTopRightRadius: isTop ? instant : radiusT,
borderBottomLeftRadius: isTop ? radiusT : instant,
borderBottomRightRadius: isTop ? radiusT : instant,
}
}
style={{
transformOrigin: isTop ? "bottom" : "top",
overflow: "hidden",
pointerEvents: open ? "auto" : "none",
}}
// flush against the trigger, then separates into its own rounded pill;
// sits above or below depending on available space
className={cn(
"absolute left-0 right-0 z-20 rounded-xl border border-border bg-background shadow-lg",
isTop ? "bottom-full" : "top-full",
className,
)}
>
<motion.div
ref={innerRef}
variants={ctx.reduce ? undefined : LIST_VARIANTS}
initial={false}
animate={open ? "show" : "hidden"}
className="p-1"
>
{children}
</motion.div>
</motion.div>
);
}
export interface SelectItemProps {
value: string;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export function SelectItem({
value,
disabled = false,
className,
children,
}: SelectItemProps) {
const ctx = useSelectContext("SelectItem");
const selected = ctx.value === value;
const label = typeof children === "string" ? children : value;
useLayoutEffect(() => {
ctx.register(value, label);
return () => ctx.unregister(value);
}, [ctx.register, ctx.unregister, value, label]);
return (
<motion.li variants={ctx.reduce ? undefined : ITEM_VARIANTS}>
<button
type="button"
role="option"
aria-selected={selected}
disabled={disabled}
onClick={() => ctx.select(value)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors",
selected
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{children}
{selected ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
</motion.li>
);
}
Morph Select
select-morph.tsxComposable primitives (MorphSelect, MorphSelectTrigger, MorphSelectValue, MorphSelectContent, MorphSelectItem) where the trigger morphs into the panel via a shared layoutId — one continuous surface that grows open and shrinks back, never detaching.
TSXcomponents/previews/motion/select-morph.preview.tsx
"use client";
import { useState } from "react";
import {
MorphSelect,
MorphSelectContent,
MorphSelectItem,
MorphSelectTrigger,
MorphSelectValue,
} from "@/components/motion/select-morph";
export function SelectMorphPreview() {
const [value, setValue] = useState("next");
return (
<div className="w-56">
<MorphSelect value={value} onValueChange={setValue}>
<MorphSelectTrigger>
<MorphSelectValue placeholder="Pick a framework" />
</MorphSelectTrigger>
<MorphSelectContent>
<MorphSelectItem value="next">Next.js</MorphSelectItem>
<MorphSelectItem value="remix">Remix</MorphSelectItem>
<MorphSelectItem value="astro">Astro</MorphSelectItem>
<MorphSelectItem value="vite">Vite</MorphSelectItem>
</MorphSelectContent>
</MorphSelect>
</div>
);
}
TSXcomponents/motion/select-morph.tsx
"use client";
// beui.dev/components/motion/select
import { Check, ChevronDown } from "lucide-react";
import {
AnimatePresence,
motion,
type Transition,
useReducedMotion,
type Variants,
} from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
// Shared-layout morph: trigger box grows into the panel and back, one surface.
const MORPH: Transition = { type: "spring", duration: 0.5, bounce: 0.22 };
// Trigger and panel header share this row so the morph stays seamless.
const ROW = "flex w-full items-center justify-between gap-2 px-3.5 py-2.5 text-sm";
const LIST: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.035, delayChildren: 0.08 } },
};
const ITEM: Variants = {
hidden: { opacity: 0, y: -6, filter: "blur(3px)" },
show: { opacity: 1, y: 0, filter: "blur(0px)" },
};
interface MorphContextValue {
value: string | undefined;
open: boolean;
setOpen: (open: boolean) => void;
select: (value: string) => void;
register: (value: string, label: string) => void;
unregister: (value: string) => void;
labelFor: (value: string | undefined) => string | undefined;
placeholder: string;
setPlaceholder: (p: string) => void;
reduce: boolean;
layoutId: string;
triggerId: string;
listId: string;
disabled: boolean;
}
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphSelect>`);
return ctx;
}
export interface MorphSelectProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
className?: string;
children: ReactNode;
}
/**
* Select whose trigger morphs into the panel via a shared layoutId — instead of
* a separate dropdown opening, the trigger itself grows into the menu and
* shrinks back, never detaching. Composable like `Select` (the gooey variant).
*/
export function MorphSelect({
value,
defaultValue,
onValueChange,
disabled = false,
className,
children,
}: MorphSelectProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState(defaultValue);
// ref-counted: items render twice (hidden registrar + open panel), so a
// label is only dropped once every copy with that value has unmounted.
const [labels, setLabels] = useState<
Map<string, { label: string; count: number }>
>(new Map());
const [placeholder, setPlaceholder] = useState("Select");
const controlled = value !== undefined;
const current = controlled ? value : internal;
const select = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
setOpen(false);
},
[controlled, onValueChange],
);
const register = useCallback((v: string, label: string) => {
setLabels((m) => {
const next = new Map(m);
next.set(v, { label, count: (m.get(v)?.count ?? 0) + 1 });
return next;
});
}, []);
const unregister = useCallback((v: string) => {
setLabels((m) => {
const entry = m.get(v);
if (!entry) return m;
const next = new Map(m);
if (entry.count <= 1) next.delete(v);
else next.set(v, { label: entry.label, count: entry.count - 1 });
return next;
});
}, []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
const onPointer = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open]);
const ctx = useMemo<MorphContextValue>(
() => ({
value: current,
open,
setOpen,
select,
register,
unregister,
labelFor: (v) => (v === undefined ? undefined : labels.get(v)?.label),
placeholder,
setPlaceholder,
reduce,
layoutId: `${baseId}-surface`,
triggerId: `${baseId}-trigger`,
listId: `${baseId}-list`,
disabled,
}),
[
current,
open,
select,
register,
unregister,
labels,
placeholder,
reduce,
baseId,
disabled,
],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={rootRef} className={cn("relative", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphSelectValueProps {
placeholder?: string;
className?: string;
}
export function MorphSelectValue({
placeholder,
className,
}: MorphSelectValueProps) {
const ctx = useMorphContext("MorphSelectValue");
// surface the placeholder so the morph header (rendered by content) matches
useEffect(() => {
if (placeholder) ctx.setPlaceholder(placeholder);
}, [placeholder, ctx.setPlaceholder]);
const label = ctx.labelFor(ctx.value);
return (
<span
className={cn(label ? "text-foreground" : "text-muted-foreground", className)}
>
{label ?? placeholder ?? "Select"}
</span>
);
}
export interface MorphSelectTriggerProps {
className?: string;
children: ReactNode;
}
export function MorphSelectTrigger({
className,
children,
}: MorphSelectTriggerProps) {
const ctx = useMorphContext("MorphSelectTrigger");
return (
<>
{/* invisible sizer reserves the closed height (the morph surface is
absolute, so this keeps surrounding layout from shifting) */}
<div
aria-hidden
className={cn(ROW, "invisible rounded-xl border border-border")}
>
{children}
<ChevronDown className="h-4 w-4" />
</div>
<AnimatePresence initial={false} mode="popLayout">
{!ctx.open ? (
<motion.button
key="trigger"
layoutId={ctx.layoutId}
type="button"
id={ctx.triggerId}
disabled={ctx.disabled}
aria-haspopup="listbox"
aria-expanded={ctx.open}
aria-controls={ctx.listId}
onClick={() => ctx.setOpen(true)}
transition={ctx.reduce ? { duration: 0 } : MORPH}
style={{ borderRadius: 12 }}
className={cn(
ROW,
"absolute inset-x-0 top-0 z-10 border border-border bg-background text-foreground outline-none transition-colors",
"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
<motion.span layout="position" className="min-w-0 truncate">
{children}
</motion.span>
<motion.span layout="position" className="text-muted-foreground">
<ChevronDown className="h-4 w-4" />
</motion.span>
</motion.button>
) : null}
</AnimatePresence>
</>
);
}
export interface MorphSelectContentProps {
className?: string;
children: ReactNode;
}
export function MorphSelectContent({
className,
children,
}: MorphSelectContentProps) {
const ctx = useMorphContext("MorphSelectContent");
const label = ctx.labelFor(ctx.value);
return (
<>
{/* always-mounted, hidden — keeps item label registrations alive while
closed so the trigger shows the selected value before first open */}
<div className="hidden">{children}</div>
<AnimatePresence initial={false} mode="popLayout">
{ctx.open ? (
<motion.div
key="panel"
layoutId={ctx.layoutId}
id={ctx.listId}
role="listbox"
aria-labelledby={ctx.triggerId}
transition={ctx.reduce ? { duration: 0 } : MORPH}
style={{ borderRadius: 12 }}
className={cn(
"absolute inset-x-0 top-0 z-30 overflow-hidden border border-border bg-background shadow-lg",
className,
)}
>
{/* header mirrors the trigger (continuous morph) and collapses the
panel back into the trigger when clicked */}
<motion.button
type="button"
layout="position"
aria-expanded
onClick={() => ctx.setOpen(false)}
className={cn(ROW, "outline-none")}
>
<span
className={cn(
"min-w-0 truncate",
label ? "text-foreground" : "text-muted-foreground",
)}
>
{label ?? ctx.placeholder}
</span>
<motion.span
animate={{ rotate: 180 }}
transition={ctx.reduce ? { duration: 0 } : MORPH}
className="text-muted-foreground"
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</motion.button>
<div className="h-px bg-border" />
<motion.ul
initial="hidden"
animate="show"
variants={ctx.reduce ? undefined : LIST}
className="p-1"
>
{children}
</motion.ul>
</motion.div>
) : null}
</AnimatePresence>
</>
);
}
export interface MorphSelectItemProps {
value: string;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export function MorphSelectItem({
value,
disabled = false,
className,
children,
}: MorphSelectItemProps) {
const ctx = useMorphContext("MorphSelectItem");
const selected = ctx.value === value;
const label = typeof children === "string" ? children : value;
useLayoutEffect(() => {
ctx.register(value, label);
return () => ctx.unregister(value);
}, [ctx.register, ctx.unregister, value, label]);
return (
<motion.li variants={ctx.reduce ? undefined : ITEM}>
<button
type="button"
role="option"
aria-selected={selected}
disabled={disabled}
onClick={() => ctx.select(value)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors",
selected
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{children}
{selected ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
</motion.li>
);
}
Install
$ bunx --bun shadcn add @beui/select-morph
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util file
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/select-morph.tsx
"use client";
// beui.dev/components/motion/select
import { Check, ChevronDown } from "lucide-react";
import {
AnimatePresence,
motion,
type Transition,
useReducedMotion,
type Variants,
} from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
// Shared-layout morph: trigger box grows into the panel and back, one surface.
const MORPH: Transition = { type: "spring", duration: 0.5, bounce: 0.22 };
// Trigger and panel header share this row so the morph stays seamless.
const ROW = "flex w-full items-center justify-between gap-2 px-3.5 py-2.5 text-sm";
const LIST: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.035, delayChildren: 0.08 } },
};
const ITEM: Variants = {
hidden: { opacity: 0, y: -6, filter: "blur(3px)" },
show: { opacity: 1, y: 0, filter: "blur(0px)" },
};
interface MorphContextValue {
value: string | undefined;
open: boolean;
setOpen: (open: boolean) => void;
select: (value: string) => void;
register: (value: string, label: string) => void;
unregister: (value: string) => void;
labelFor: (value: string | undefined) => string | undefined;
placeholder: string;
setPlaceholder: (p: string) => void;
reduce: boolean;
layoutId: string;
triggerId: string;
listId: string;
disabled: boolean;
}
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphSelect>`);
return ctx;
}
export interface MorphSelectProps {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
className?: string;
children: ReactNode;
}
/**
* Select whose trigger morphs into the panel via a shared layoutId — instead of
* a separate dropdown opening, the trigger itself grows into the menu and
* shrinks back, never detaching. Composable like `Select` (the gooey variant).
*/
export function MorphSelect({
value,
defaultValue,
onValueChange,
disabled = false,
className,
children,
}: MorphSelectProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [internal, setInternal] = useState(defaultValue);
// ref-counted: items render twice (hidden registrar + open panel), so a
// label is only dropped once every copy with that value has unmounted.
const [labels, setLabels] = useState<
Map<string, { label: string; count: number }>
>(new Map());
const [placeholder, setPlaceholder] = useState("Select");
const controlled = value !== undefined;
const current = controlled ? value : internal;
const select = useCallback(
(next: string) => {
if (!controlled) setInternal(next);
onValueChange?.(next);
setOpen(false);
},
[controlled, onValueChange],
);
const register = useCallback((v: string, label: string) => {
setLabels((m) => {
const next = new Map(m);
next.set(v, { label, count: (m.get(v)?.count ?? 0) + 1 });
return next;
});
}, []);
const unregister = useCallback((v: string) => {
setLabels((m) => {
const entry = m.get(v);
if (!entry) return m;
const next = new Map(m);
if (entry.count <= 1) next.delete(v);
else next.set(v, { label: entry.label, count: entry.count - 1 });
return next;
});
}, []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
const onPointer = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node))
setOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open]);
const ctx = useMemo<MorphContextValue>(
() => ({
value: current,
open,
setOpen,
select,
register,
unregister,
labelFor: (v) => (v === undefined ? undefined : labels.get(v)?.label),
placeholder,
setPlaceholder,
reduce,
layoutId: `${baseId}-surface`,
triggerId: `${baseId}-trigger`,
listId: `${baseId}-list`,
disabled,
}),
[
current,
open,
select,
register,
unregister,
labels,
placeholder,
reduce,
baseId,
disabled,
],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={rootRef} className={cn("relative", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphSelectValueProps {
placeholder?: string;
className?: string;
}
export function MorphSelectValue({
placeholder,
className,
}: MorphSelectValueProps) {
const ctx = useMorphContext("MorphSelectValue");
// surface the placeholder so the morph header (rendered by content) matches
useEffect(() => {
if (placeholder) ctx.setPlaceholder(placeholder);
}, [placeholder, ctx.setPlaceholder]);
const label = ctx.labelFor(ctx.value);
return (
<span
className={cn(label ? "text-foreground" : "text-muted-foreground", className)}
>
{label ?? placeholder ?? "Select"}
</span>
);
}
export interface MorphSelectTriggerProps {
className?: string;
children: ReactNode;
}
export function MorphSelectTrigger({
className,
children,
}: MorphSelectTriggerProps) {
const ctx = useMorphContext("MorphSelectTrigger");
return (
<>
{/* invisible sizer reserves the closed height (the morph surface is
absolute, so this keeps surrounding layout from shifting) */}
<div
aria-hidden
className={cn(ROW, "invisible rounded-xl border border-border")}
>
{children}
<ChevronDown className="h-4 w-4" />
</div>
<AnimatePresence initial={false} mode="popLayout">
{!ctx.open ? (
<motion.button
key="trigger"
layoutId={ctx.layoutId}
type="button"
id={ctx.triggerId}
disabled={ctx.disabled}
aria-haspopup="listbox"
aria-expanded={ctx.open}
aria-controls={ctx.listId}
onClick={() => ctx.setOpen(true)}
transition={ctx.reduce ? { duration: 0 } : MORPH}
style={{ borderRadius: 12 }}
className={cn(
ROW,
"absolute inset-x-0 top-0 z-10 border border-border bg-background text-foreground outline-none transition-colors",
"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
<motion.span layout="position" className="min-w-0 truncate">
{children}
</motion.span>
<motion.span layout="position" className="text-muted-foreground">
<ChevronDown className="h-4 w-4" />
</motion.span>
</motion.button>
) : null}
</AnimatePresence>
</>
);
}
export interface MorphSelectContentProps {
className?: string;
children: ReactNode;
}
export function MorphSelectContent({
className,
children,
}: MorphSelectContentProps) {
const ctx = useMorphContext("MorphSelectContent");
const label = ctx.labelFor(ctx.value);
return (
<>
{/* always-mounted, hidden — keeps item label registrations alive while
closed so the trigger shows the selected value before first open */}
<div className="hidden">{children}</div>
<AnimatePresence initial={false} mode="popLayout">
{ctx.open ? (
<motion.div
key="panel"
layoutId={ctx.layoutId}
id={ctx.listId}
role="listbox"
aria-labelledby={ctx.triggerId}
transition={ctx.reduce ? { duration: 0 } : MORPH}
style={{ borderRadius: 12 }}
className={cn(
"absolute inset-x-0 top-0 z-30 overflow-hidden border border-border bg-background shadow-lg",
className,
)}
>
{/* header mirrors the trigger (continuous morph) and collapses the
panel back into the trigger when clicked */}
<motion.button
type="button"
layout="position"
aria-expanded
onClick={() => ctx.setOpen(false)}
className={cn(ROW, "outline-none")}
>
<span
className={cn(
"min-w-0 truncate",
label ? "text-foreground" : "text-muted-foreground",
)}
>
{label ?? ctx.placeholder}
</span>
<motion.span
animate={{ rotate: 180 }}
transition={ctx.reduce ? { duration: 0 } : MORPH}
className="text-muted-foreground"
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</motion.button>
<div className="h-px bg-border" />
<motion.ul
initial="hidden"
animate="show"
variants={ctx.reduce ? undefined : LIST}
className="p-1"
>
{children}
</motion.ul>
</motion.div>
) : null}
</AnimatePresence>
</>
);
}
export interface MorphSelectItemProps {
value: string;
disabled?: boolean;
className?: string;
children: ReactNode;
}
export function MorphSelectItem({
value,
disabled = false,
className,
children,
}: MorphSelectItemProps) {
const ctx = useMorphContext("MorphSelectItem");
const selected = ctx.value === value;
const label = typeof children === "string" ? children : value;
useLayoutEffect(() => {
ctx.register(value, label);
return () => ctx.unregister(value);
}, [ctx.register, ctx.unregister, value, label]);
return (
<motion.li variants={ctx.reduce ? undefined : ITEM}>
<button
type="button"
role="option"
aria-selected={selected}
disabled={disabled}
onClick={() => ctx.select(value)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors",
selected
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{children}
{selected ? <Check className="h-3.5 w-3.5 shrink-0" /> : null}
</button>
</motion.li>
);
}