Availability Scheduler
NewWeekly availability editor where each day springs between available and unavailable, time ranges add and remove with blur-slide motion, times pick from a scrollable dropdown, and a copy menu clones hours to other days.
"use client";
import { AvailabilityScheduler } from "@/components/motion/availability-scheduler";
export function AvailabilitySchedulerPreview() {
return (
<div className="flex w-full justify-center">
<AvailabilityScheduler />
</div>
);
}
"use client";
// beui.dev/components/blocks/availability-scheduler
import { LayoutGroup, useReducedMotion } from "motion/react";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { DayRow } from "./day-row";
import {
type DayAvailability,
type DayKey,
WEEKDAYS,
type WeekAvailability,
buildOptions,
defaultWeek,
} from "./types";
export type {
DayAvailability,
DayKey,
TimeRange,
WeekAvailability,
} from "./types";
export { defaultWeek } from "./types";
export interface AvailabilitySchedulerProps {
value?: WeekAvailability;
defaultValue?: WeekAvailability;
onChange?: (value: WeekAvailability) => void;
/** Minutes between selectable times. Default 30. */
step?: number;
className?: string;
}
export function AvailabilityScheduler({
value,
defaultValue,
onChange,
step = 30,
className,
}: AvailabilitySchedulerProps) {
const reduce = useReducedMotion() ?? false;
const groupId = useId();
const options = useMemo(() => buildOptions(step), [step]);
const idRef = useRef(0);
const [internal, setInternal] = useState<WeekAvailability>(
() => defaultValue ?? defaultWeek(),
);
const controlled = value !== undefined;
const week = controlled ? value : internal;
const commit = useCallback(
(next: WeekAvailability) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
const setDay = useCallback(
(day: DayKey, next: DayAvailability) => {
commit({ ...week, [day]: next });
},
[commit, week],
);
const copyDay = useCallback(
(from: DayKey, targets: DayKey[]) => {
const source = week[from];
const next = { ...week };
for (const t of targets) {
next[t] = {
enabled: source.enabled,
ranges: source.ranges.map((r) => ({
...r,
id: `${t}-c${idRef.current++}`,
})),
};
}
commit(next);
},
[commit, week],
);
return (
<LayoutGroup id={groupId}>
<div className={cn("w-full max-w-xl divide-y divide-border", className)}>
{WEEKDAYS.map(({ key, label }, i) => (
<DayRow
key={key}
day={key}
label={label}
state={week[key]}
options={options}
reduce={reduce}
depth={WEEKDAYS.length - i}
onChange={(next) => setDay(key, next)}
onCopy={(targets) => copyDay(key, targets)}
/>
))}
</div>
</LayoutGroup>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// beui.dev/components/blocks/availability-scheduler
import { LayoutGroup, useReducedMotion } from "motion/react";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { DayRow } from "./day-row";
import {
type DayAvailability,
type DayKey,
WEEKDAYS,
type WeekAvailability,
buildOptions,
defaultWeek,
} from "./types";
export type {
DayAvailability,
DayKey,
TimeRange,
WeekAvailability,
} from "./types";
export { defaultWeek } from "./types";
export interface AvailabilitySchedulerProps {
value?: WeekAvailability;
defaultValue?: WeekAvailability;
onChange?: (value: WeekAvailability) => void;
/** Minutes between selectable times. Default 30. */
step?: number;
className?: string;
}
export function AvailabilityScheduler({
value,
defaultValue,
onChange,
step = 30,
className,
}: AvailabilitySchedulerProps) {
const reduce = useReducedMotion() ?? false;
const groupId = useId();
const options = useMemo(() => buildOptions(step), [step]);
const idRef = useRef(0);
const [internal, setInternal] = useState<WeekAvailability>(
() => defaultValue ?? defaultWeek(),
);
const controlled = value !== undefined;
const week = controlled ? value : internal;
const commit = useCallback(
(next: WeekAvailability) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
const setDay = useCallback(
(day: DayKey, next: DayAvailability) => {
commit({ ...week, [day]: next });
},
[commit, week],
);
const copyDay = useCallback(
(from: DayKey, targets: DayKey[]) => {
const source = week[from];
const next = { ...week };
for (const t of targets) {
next[t] = {
enabled: source.enabled,
ranges: source.ranges.map((r) => ({
...r,
id: `${t}-c${idRef.current++}`,
})),
};
}
commit(next);
},
[commit, week],
);
return (
<LayoutGroup id={groupId}>
<div className={cn("w-full max-w-xl divide-y divide-border", className)}>
{WEEKDAYS.map(({ key, label }, i) => (
<DayRow
key={key}
day={key}
label={label}
state={week[key]}
options={options}
reduce={reduce}
depth={WEEKDAYS.length - i}
onChange={(next) => setDay(key, next)}
onCopy={(targets) => copyDay(key, targets)}
/>
))}
</div>
</LayoutGroup>
);
}
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Plus, X } from "lucide-react";
import { useRef } from "react";
import { Switch } from "@/components/motion/switch";
import { Tooltip } from "@/components/motion/tooltip";
import { SPRING_LAYOUT } from "@/lib/ease";
import { CopyMenu } from "./copy-menu";
import { IconButton } from "./icon-button";
import { TimeSelect } from "./time-select";
import {
type DayAvailability,
type DayKey,
type TimeOption,
type TimeRange,
toMinutes,
toValue,
} from "./types";
export function DayRow({
day,
label,
state,
options,
reduce,
depth,
onChange,
onCopy,
}: {
day: DayKey;
label: string;
state: DayAvailability;
options: TimeOption[];
reduce: boolean;
// Higher = painted above later rows, so a downward-opening panel always sits
// over the rows below it — during both open and close animations.
depth: number;
onChange: (next: DayAvailability) => void;
onCopy: (targets: DayKey[]) => void;
}) {
const idRef = useRef(0);
const nextId = () => `${day}-n${idRef.current++}`;
const setEnabled = (enabled: boolean) => {
if (enabled && state.ranges.length === 0) {
onChange({
enabled,
ranges: [{ id: nextId(), start: "09:00", end: "17:00" }],
});
} else {
onChange({ ...state, enabled });
}
};
const updateRange = (id: string, patch: Partial<TimeRange>) => {
onChange({
...state,
ranges: state.ranges.map((r) => (r.id === id ? { ...r, ...patch } : r)),
});
};
const addRange = () => {
const last = state.ranges[state.ranges.length - 1];
const start = last ? Math.min(toMinutes(last.end) + 60, 24 * 60 - 60) : 540;
onChange({
enabled: true,
ranges: [
...state.ranges,
{ id: nextId(), start: toValue(start), end: toValue(start + 60) },
],
});
};
const removeRange = (id: string) => {
const ranges = state.ranges.filter((r) => r.id !== id);
// Removing the last slot marks the day unavailable.
onChange({ enabled: ranges.length > 0, ranges });
};
const actions = (
<>
<Tooltip content="Add time">
<IconButton
label={`Add time range to ${label}`}
reduce={reduce}
onClick={addRange}
>
<Plus className="h-4 w-4" />
</IconButton>
</Tooltip>
<CopyMenu fromLabel={label} reduce={reduce} onApply={onCopy} />
</>
);
return (
<motion.div
layout={reduce ? false : "position"}
transition={SPRING_LAYOUT}
style={{ zIndex: depth }}
className="relative flex flex-col gap-3 py-4 sm:flex-row sm:items-start sm:gap-4"
>
{/* toggle + label; actions ride along on mobile */}
<div className="flex items-center justify-between sm:w-36 sm:shrink-0 sm:justify-start sm:pt-1">
<div className="flex items-center gap-2.5">
<Switch
checked={state.enabled}
onCheckedChange={setEnabled}
className="scale-90"
/>
<span className="text-sm font-medium text-foreground">{label}</span>
</div>
<div className="flex items-center gap-1 sm:hidden">{actions}</div>
</div>
{/* ranges or unavailable */}
<div className="flex min-w-0 flex-1 flex-col gap-2">
<AnimatePresence initial={false} mode="popLayout">
{state.enabled ? (
state.ranges.map((r, i) => (
<motion.div
key={r.id}
layout={reduce ? false : "position"}
style={{ zIndex: state.ranges.length - i }}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: -6, filter: "blur(4px)" }
}
animate={
reduce
? { opacity: 1 }
: { opacity: 1, y: 0, filter: "blur(0px)" }
}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, y: -4, filter: "blur(4px)" }
}
transition={SPRING_LAYOUT}
className="relative flex items-center gap-2"
>
<div className="min-w-0 flex-1 sm:max-w-[132px]">
<TimeSelect
value={r.start}
options={options}
onChange={(v) => updateRange(r.id, { start: v })}
/>
</div>
<span className="text-muted-foreground">–</span>
<div className="min-w-0 flex-1 sm:max-w-[132px]">
<TimeSelect
value={r.end}
options={options}
onChange={(v) => updateRange(r.id, { end: v })}
/>
</div>
<Tooltip content="Remove">
<IconButton
label="Remove time range"
reduce={reduce}
onClick={() => removeRange(r.id)}
>
<X className="h-4 w-4" />
</IconButton>
</Tooltip>
</motion.div>
))
) : (
<motion.span
key="unavailable"
layout={reduce ? false : "position"}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={SPRING_LAYOUT}
className="py-1 text-sm text-muted-foreground sm:py-2"
>
Unavailable
</motion.span>
)}
</AnimatePresence>
</div>
{/* actions (desktop) */}
<div className="hidden shrink-0 items-center gap-1 pt-0.5 sm:flex">
{actions}
</div>
</motion.div>
);
}
export type DayKey = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
export type TimeRange = { id: string; start: string; end: string };
export type DayAvailability = { enabled: boolean; ranges: TimeRange[] };
export type WeekAvailability = Record<DayKey, DayAvailability>;
export type TimeOption = { value: string; label: string };
export const WEEKDAYS: { key: DayKey; label: string }[] = [
{ key: "mon", label: "Monday" },
{ key: "tue", label: "Tuesday" },
{ key: "wed", label: "Wednesday" },
{ key: "thu", label: "Thursday" },
{ key: "fri", label: "Friday" },
{ key: "sat", label: "Saturday" },
{ key: "sun", label: "Sunday" },
];
// ─── time helpers ────────────────────────────────────────────────────────────
export const toMinutes = (v: string) => {
const [h, m] = v.split(":").map(Number);
return h * 60 + m;
};
export const toValue = (mins: number) => {
const clamped = Math.max(0, Math.min(24 * 60 - 1, mins));
const h = Math.floor(clamped / 60);
const m = clamped % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
export const label12 = (v: string) => {
const [h, m] = v.split(":").map(Number);
const ap = h < 12 ? "AM" : "PM";
const h12 = h % 12 === 0 ? 12 : h % 12;
return `${h12}:${String(m).padStart(2, "0")} ${ap}`;
};
export function buildOptions(step: number): TimeOption[] {
const out: TimeOption[] = [];
for (let m = 0; m < 24 * 60; m += step) {
const value = toValue(m);
out.push({ value, label: label12(value) });
}
return out;
}
// Default: Mon–Fri 9–5, weekend off. Fixed ids so SSR and first client render
// agree (new ranges get counter ids afterwards).
export function defaultWeek(): WeekAvailability {
const workday = (day: DayKey): DayAvailability => ({
enabled: true,
ranges: [{ id: `${day}-0`, start: "09:00", end: "17:00" }],
});
const off = (day: DayKey): DayAvailability => ({
enabled: false,
ranges: [{ id: `${day}-0`, start: "09:00", end: "17:00" }],
});
return {
mon: workday("mon"),
tue: workday("tue"),
wed: workday("wed"),
thu: workday("thu"),
fri: workday("fri"),
sat: off("sat"),
sun: off("sun"),
};
}
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Check, Copy } from "lucide-react";
import { useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
import {
MorphPopover,
MorphPopoverContent,
} from "@/components/motion/popover-morph";
import { Tooltip } from "@/components/motion/tooltip";
import { SPRING_PRESS } from "@/lib/ease";
import { IconButton } from "./icon-button";
import { type DayKey, WEEKDAYS } from "./types";
// Copy this day's hours to other days: a morph popover with a day picker.
export function CopyMenu({
fromLabel,
reduce,
onApply,
}: {
fromLabel: string;
reduce: boolean;
onApply: (targets: DayKey[]) => void;
}) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [picked, setPicked] = useState<Set<DayKey>>(new Set());
const others = WEEKDAYS.filter((d) => d.label !== fromLabel);
const toggle = (k: DayKey) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
const apply = (targets: DayKey[]) => {
if (!targets.length) return;
onApply(targets);
setOpen(false);
setPicked(new Set());
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
return (
<MorphPopover open={open} onOpenChange={setOpen}>
<Tooltip content="Copy times">
<IconButton
label={`Copy ${fromLabel} hours to other days`}
reduce={reduce}
expanded={open}
onClick={() => setOpen(!open)}
>
<AnimatePresence mode="popLayout" initial={false}>
{copied ? (
<motion.span
key="done"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={SPRING_PRESS}
className="text-foreground"
>
<Check className="h-4 w-4" />
</motion.span>
) : (
<motion.span
key="copy"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={SPRING_PRESS}
>
<Copy className="h-4 w-4" />
</motion.span>
)}
</AnimatePresence>
</IconButton>
</Tooltip>
<MorphPopoverContent align="end" className="w-52 p-2">
<p className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Copy times to
</p>
<div className="flex flex-col">
{others.map((d) => (
<Checkbox
key={d.key}
checked={picked.has(d.key)}
onCheckedChange={() => toggle(d.key)}
label={d.label}
className="w-full flex-row-reverse justify-between rounded-lg px-2 py-1.5 transition-colors hover:bg-muted [&_button]:size-4 [&_button]:rounded-[5px] [&_button]:border [&_button[data-state=unchecked]]:border-border-strong"
/>
))}
</div>
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
<button
type="button"
onClick={() => apply(others.map((d) => d.key))}
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted"
>
Every day
</button>
<button
type="button"
onClick={() => apply([...picked])}
disabled={picked.size === 0}
className="flex-1 rounded-lg bg-primary px-2 py-1.5 text-xs font-semibold text-primary-foreground outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
>
Apply
</button>
</div>
</MorphPopoverContent>
</MorphPopover>
);
}
"use client";
import { motion } from "motion/react";
import type { ReactNode } from "react";
import { SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export function IconButton({
onClick,
label,
disabled,
expanded,
reduce,
children,
className,
// Rest props let a wrapping Tooltip inject its hover/focus handlers.
...rest
}: {
onClick: () => void;
label: string;
disabled?: boolean;
expanded?: boolean;
reduce: boolean;
children: ReactNode;
className?: string;
[key: string]: unknown;
}) {
return (
<motion.button
{...rest}
type="button"
aria-label={label}
aria-expanded={expanded}
onClick={onClick}
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.86 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40",
className,
)}
>
{children}
</motion.button>
);
}
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/motion/select";
import type { TimeOption } from "./types";
// Time field: the library Select, with the option list capped so the panel
// measures a small height and scrolls instead of unfolding all 48 options.
export function TimeSelect({
value,
onChange,
options,
}: {
value: string;
onChange: (v: string) => void;
options: TimeOption[];
}) {
return (
<Select value={value} onValueChange={onChange} className="w-full">
<SelectTrigger className="tabular-nums">
<SelectValue className="whitespace-nowrap" />
</SelectTrigger>
<SelectContent>
<div className="max-h-56 overflow-y-auto overscroll-contain">
{options.map((o) => (
<SelectItem key={o.value} value={o.value} className="tabular-nums">
{o.label}
</SelectItem>
))}
</div>
</SelectContent>
</Select>
);
}
"use client";
import { animate, motion, MotionConfig, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
// Heavy, deliberate thumb — high mass keeps the travel weighty without wobble.
const THUMB_SPRING = { type: "spring", stiffness: 800, damping: 80, mass: 4 } as const;
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
className?: string;
}
export function Switch({ checked, onCheckedChange, disabled, label, className }: SwitchProps) {
const id = useId();
const thumbRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const [isPressed, setIsPressed] = useState(false);
const [isPointer, setIsPointer] = useState(false);
// Disabled shake feedback when pressed.
useEffect(() => {
if (!thumbRef.current || reduce) return;
if (disabled && isPressed) {
animate(
thumbRef.current,
{ x: [0, -2, 2, -1, 0] },
{ delay: 0.2, duration: 0.6 },
);
}
}, [disabled, isPressed, reduce]);
const squish = !disabled && isPointer && isPressed && !reduce;
return (
<MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>
<span className={cn("inline-flex items-center gap-3", className)}>
<motion.button
id={id}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
onPointerDown={(e) => {
setIsPressed(true);
setIsPointer(e.type.startsWith("pointer"));
}}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
initial={false}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"group peer inline-flex h-7 w-12 shrink-0 cursor-pointer items-center px-1 rounded-full outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
checked ? "justify-end bg-primary" : "justify-start bg-muted-foreground/60",
)}
>
<motion.div
ref={thumbRef}
layout
animate={{ scale: squish ? 0.9 : 1 }}
className="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md"
>
{/* Stretch toward the destination while active. */}
<div
className={cn(
"size-5",
squish && (checked ? "ml-1" : "mr-1"),
)}
/>
</motion.div>
</motion.button>
{label ? (
<label htmlFor={id} className="cursor-pointer text-sm text-foreground">
{label}
</label>
) : null}
</span>
</MotionConfig>
);
}
"use client";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
cloneElement,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
type Side = "top" | "right" | "bottom" | "left";
export interface TooltipProps {
content: ReactNode;
children: ReactElement;
side?: Side;
/** Delay before showing (ms). Default 120. */
delay?: number;
className?: string;
/** Classes for the outer wrapper span. Use to fix baseline / fill parent. */
wrapperClassName?: string;
}
// Gap between trigger and tooltip, in px.
const GAP = 8;
// Centering transform for the fixed-positioned anchor point, per side.
const anchorTransform: Record<Side, string> = {
top: "translate(-50%, -100%)",
bottom: "translate(-50%, 0)",
left: "translate(-100%, -50%)",
right: "translate(0, -50%)",
};
const transformOrigin: Record<Side, string> = {
top: "center bottom",
bottom: "center top",
left: "right center",
right: "left center",
};
// Offset is in the direction *away* from the trigger — content originates near
// the trigger and rises into resting position.
const offsetFrom: Record<Side, { x?: number; y?: number }> = {
top: { y: 8 },
bottom: { y: -8 },
left: { x: 8 },
right: { x: -8 },
};
function buildVariants(side: Side): Variants {
const o = offsetFrom[side];
return {
initial: {
opacity: 0,
scale: 0.9,
filter: "blur(5px)",
x: o.x ?? 0,
y: o.y ?? 0,
},
animate: {
opacity: 1,
scale: 1,
filter: "blur(0px)",
x: 0,
y: 0,
transition: {
type: "spring",
stiffness: 380,
damping: 30,
mass: 0.7,
opacity: { duration: 0.14, ease: EASE_OUT },
filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
scale: 0.94,
filter: "blur(3px)",
x: (o.x ?? 0) * 0.6,
y: (o.y ?? 0) * 0.6,
transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
const REDUCED_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },
exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },
};
// Once any tooltip has just closed, neighbouring tooltips open without the
// initial delay — moving along a toolbar feels instant after the first one.
const WARM_WINDOW_MS = 300;
let lastHiddenAt = 0;
export function Tooltip({
content,
children,
side = "top",
delay = 120,
className,
wrapperClassName,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number } | null>(
null,
);
const id = useId();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const anchorRef = useRef<HTMLSpanElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Anchor point in viewport coords, on the edge of the trigger facing `side`.
// Position:fixed means these viewport coords place the tooltip directly, so
// it escapes every ancestor's stacking context and overflow.
const place = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
const point: Record<Side, { top: number; left: number }> = {
top: { top: r.top - GAP, left: cx },
bottom: { top: r.bottom + GAP, left: cx },
left: { top: cy, left: r.left - GAP },
right: { top: cy, left: r.right + GAP },
};
setCoords(point[side]);
}, [side]);
const show = useCallback(() => {
if (!canHover) return;
if (timer.current) clearTimeout(timer.current);
const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;
timer.current = setTimeout(
() => {
place();
setOpen(true);
},
warm ? 0 : delay,
);
}, [canHover, delay, place]);
const hide = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
setOpen((wasOpen) => {
if (wasOpen) lastHiddenAt = Date.now();
return false;
});
}, []);
// Keep the tooltip pinned to the trigger while it's open and the page scrolls
// or resizes (fixed coords are viewport-relative).
useEffect(() => {
if (!open) return;
const onMove = () => place();
window.addEventListener("scroll", onMove, true);
window.addEventListener("resize", onMove);
return () => {
window.removeEventListener("scroll", onMove, true);
window.removeEventListener("resize", onMove);
};
}, [open, place]);
const variants = useMemo(
() => (reduce ? REDUCED_VARIANTS : buildVariants(side)),
[reduce, side],
);
if (!isValidElement(children)) return children;
const trigger = cloneElement(
children as ReactElement<Record<string, unknown>>,
{
onMouseEnter: show,
onMouseLeave: hide,
onFocus: show,
onBlur: hide,
"aria-describedby": id,
},
);
return (
<>
<span
ref={anchorRef}
className={cn("relative inline-flex align-middle", wrapperClassName)}
>
{trigger}
</span>
{typeof document !== "undefined"
? createPortal(
<AnimatePresence>
{open && coords ? (
<span
aria-hidden
className="pointer-events-none fixed z-[9999]"
style={{
top: coords.top,
left: coords.left,
transform: anchorTransform[side],
}}
>
<motion.span
id={id}
role="tooltip"
variants={variants}
initial="initial"
animate="animate"
exit="exit"
style={{
transformOrigin: transformOrigin[side],
willChange: "transform, opacity",
}}
className={cn(
"block whitespace-nowrap rounded-lg border border-border bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-lg",
className,
)}
>
{content}
</motion.span>
</span>
) : null}
</AnimatePresence>,
document.body,
)
: null}
</>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
className?: string;
id?: string;
"aria-label"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
"aria-label": ariaLabel,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
return (
<label
htmlFor={id}
className={cn(
"inline-flex items-center gap-3",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<span className={cn("select-none text-sm text-foreground", disabled && "opacity-60")}>
{label}
</span>
) : null}
</label>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { 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;
};
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 rootRef = useRef<HTMLDivElement>(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]);
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, setOpen]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
}),
[open, setOpen, toggle, baseId],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={rootRef} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
return cloneElement(child, {
id: ctx.triggerId,
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "menu",
"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 clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
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;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const posClass = cn(
side === "bottom" ? "top-full" : "bottom-full",
align === "end" ? "right-0" : "left-0",
);
const marginStyle =
side === "bottom" ? { marginTop: sideOffset } : { marginBottom: sideOffset };
// Close animates with the same spring as open, so it morphs back to the
// corner instead of snapping shut.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96 },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
exit: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: { clipPath: clipHidden(side, align, radius) },
show: { clipPath: clipShown(radius), transition: SPRING_PANEL },
exit: { clipPath: clipHidden(side, align, radius), transition: SPRING_PANEL },
};
return (
<AnimatePresence>
{ctx.open ? (
<motion.div
// 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={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{ transformOrigin: originFor(side, align), ...marginStyle }}
className={cn(
"absolute z-30 [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]",
posClass,
)}
>
<motion.div
id={ctx.contentId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
"use client";
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>
);
}
API Reference
value?any—defaultValue?any—onChange?((value: Record<DayKey, DayAvailability>) => void)—step?numberMinutes between selectable times. Default 30.
30className?string—Related components
Multi-chain Swap
Cross-chain swap widget with chain + token selectors, morphing views, animated flip and quote.
Dynamic Island
iOS-style island pill that morphs between live activity views with bouncy shell resize and blur crossfades.
Command Palette
⌘K palette with fuzzy filter, spring-animated active row and glass surface.
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.