+
diff --git a/components/motion/availability-scheduler/copy-menu.tsx b/components/motion/availability-scheduler/copy-menu.tsx
new file mode 100644
index 0000000..14b8d48
--- /dev/null
+++ b/components/motion/availability-scheduler/copy-menu.tsx
@@ -0,0 +1,119 @@
+"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
>(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 (
+
+
+ setOpen(!open)}
+ >
+
+ {copied ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+ Copy times to
+
+
+ {others.map((d) => (
+ 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"
+ />
+ ))}
+
+
+ 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
+
+ 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
+
+
+
+
+ );
+}
diff --git a/components/motion/availability-scheduler/day-row.tsx b/components/motion/availability-scheduler/day-row.tsx
new file mode 100644
index 0000000..99fbe35
--- /dev/null
+++ b/components/motion/availability-scheduler/day-row.tsx
@@ -0,0 +1,191 @@
+"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) => {
+ 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 = (
+ <>
+
+
+
+
+
+
+ >
+ );
+
+ return (
+
+ {/* toggle + label; actions ride along on mobile */}
+
+
+
+ {label}
+
+
{actions}
+
+
+ {/* ranges or unavailable */}
+
+
+ {state.enabled ? (
+ state.ranges.map((r, i) => (
+
+
+ updateRange(r.id, { start: v })}
+ />
+
+ –
+
+ updateRange(r.id, { end: v })}
+ />
+
+
+ removeRange(r.id)}
+ >
+
+
+
+
+ ))
+ ) : (
+
+ Unavailable
+
+ )}
+
+
+
+ {/* actions (desktop) */}
+
+ {actions}
+
+
+ );
+}
diff --git a/components/motion/availability-scheduler/icon-button.tsx b/components/motion/availability-scheduler/icon-button.tsx
new file mode 100644
index 0000000..859070f
--- /dev/null
+++ b/components/motion/availability-scheduler/icon-button.tsx
@@ -0,0 +1,46 @@
+"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 (
+
+ {children}
+
+ );
+}
diff --git a/components/motion/availability-scheduler/index.tsx b/components/motion/availability-scheduler/index.tsx
new file mode 100644
index 0000000..adee322
--- /dev/null
+++ b/components/motion/availability-scheduler/index.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+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(
+ () => 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 (
+
+
+ {WEEKDAYS.map(({ key, label }, i) => (
+ setDay(key, next)}
+ onCopy={(targets) => copyDay(key, targets)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/components/motion/availability-scheduler/time-select.tsx b/components/motion/availability-scheduler/time-select.tsx
new file mode 100644
index 0000000..6920dd5
--- /dev/null
+++ b/components/motion/availability-scheduler/time-select.tsx
@@ -0,0 +1,39 @@
+"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 (
+
+
+
+
+
+
+ {options.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/motion/availability-scheduler/types.ts b/components/motion/availability-scheduler/types.ts
new file mode 100644
index 0000000..13738f6
--- /dev/null
+++ b/components/motion/availability-scheduler/types.ts
@@ -0,0 +1,68 @@
+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;
+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"),
+ };
+}
diff --git a/components/motion/popover-morph.tsx b/components/motion/popover-morph.tsx
new file mode 100644
index 0000000..19cce5b
--- /dev/null
+++ b/components/motion/popover-morph.tsx
@@ -0,0 +1,230 @@
+"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(null);
+
+function useMorphContext(component: string) {
+ const ctx = useContext(MorphContext);
+ if (!ctx) throw new Error(`${component} must be used within `);
+ 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(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(
+ () => ({
+ open,
+ setOpen,
+ toggle,
+ triggerId: `${baseId}-trigger`,
+ contentId: `${baseId}-content`,
+ }),
+ [open, setOpen, toggle, baseId],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+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>;
+ 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 (
+
+ {ctx.open ? (
+
+
+ {children}
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/motion/popover.tsx b/components/motion/popover.tsx
index 574d3c3..5d97ef9 100644
--- a/components/motion/popover.tsx
+++ b/components/motion/popover.tsx
@@ -29,7 +29,11 @@ type Side = "top" | "bottom";
type Align = "start" | "center" | "end";
type TriggerMode = "click" | "hover";
-const GOO_SPRING = { type: "spring", visualDuration: 0.32, bounce: 0.28 } as const;
+const GOO_SPRING = {
+ type: "spring",
+ visualDuration: 0.32,
+ bounce: 0.28,
+} as const;
const HOVER_CLOSE_DELAY = 120;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
@@ -401,7 +405,17 @@ export function PopoverContent({ children, className }: PopoverContentProps) {
}, [triggerRef]);
const geo = useMemo(
- () => buildGeo(sizes.tW, sizes.tH, sizes.cW, sizes.cH, side, align, gap, panelRadius),
+ () =>
+ buildGeo(
+ sizes.tW,
+ sizes.tH,
+ sizes.cW,
+ sizes.cH,
+ side,
+ align,
+ gap,
+ panelRadius,
+ ),
[sizes, side, align, gap, panelRadius],
);
geoRef.current = geo;
@@ -430,11 +444,20 @@ export function PopoverContent({ children, className }: PopoverContentProps) {
<>
{/* Goo filter: blur, sharpen the alpha back into solid shapes, then lay
the crisp original on top so blobs merge with liquid edges. */}
-
+
Popover goo filter
-
+
= {
- top: "bottom-full left-1/2 mb-2 -translate-x-1/2",
- bottom: "top-full left-1/2 mt-2 -translate-x-1/2",
- left: "right-full top-1/2 mr-2 -translate-y-1/2",
- right: "left-full top-1/2 ml-2 -translate-y-1/2",
+// Gap between trigger and tooltip, in px.
+const GAP = 8;
+
+// Centering transform for the fixed-positioned anchor point, per side.
+const anchorTransform: Record
= {
+ top: "translate(-50%, -100%)",
+ bottom: "translate(-50%, 0)",
+ left: "translate(-100%, -50%)",
+ right: "translate(0, -50%)",
};
const transformOrigin: Record = {
@@ -44,10 +57,10 @@ const transformOrigin: Record = {
// Offset is in the direction *away* from the trigger — content originates near
// the trigger and rises into resting position.
const offsetFrom: Record = {
- top: { y: 10 },
- bottom: { y: -10 },
- left: { x: 10 },
- right: { x: -10 },
+ top: { y: 8 },
+ bottom: { y: -8 },
+ left: { x: 8 },
+ right: { x: -8 },
};
function buildVariants(side: Side): Variants {
@@ -55,8 +68,8 @@ function buildVariants(side: Side): Variants {
return {
initial: {
opacity: 0,
- scale: 0.85,
- filter: "blur(10px)",
+ scale: 0.9,
+ filter: "blur(5px)",
x: o.x ?? 0,
y: o.y ?? 0,
},
@@ -71,17 +84,17 @@ function buildVariants(side: Side): Variants {
stiffness: 380,
damping: 30,
mass: 0.7,
- opacity: { duration: 0.22, ease: EASE_OUT },
- filter: { duration: 0.3, ease: EASE_OUT },
+ opacity: { duration: 0.14, ease: EASE_OUT },
+ filter: { duration: 0.18, ease: EASE_OUT },
},
},
exit: {
opacity: 0,
- scale: 0.92,
- filter: "blur(6px)",
+ scale: 0.94,
+ filter: "blur(3px)",
x: (o.x ?? 0) * 0.6,
y: (o.y ?? 0) * 0.6,
- transition: { duration: 0.14, ease: EASE_OUT },
+ transition: { duration: 0.12, ease: EASE_OUT },
},
};
}
@@ -97,67 +110,142 @@ const REDUCED_VARIANTS: Variants = {
const WARM_WINDOW_MS = 300;
let lastHiddenAt = 0;
-export function Tooltip({ content, children, side = "top", delay = 120, className, wrapperClassName }: TooltipProps) {
+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 | null>(null);
+ const anchorRef = useRef(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
- const show = () => {
+ // 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 = {
+ 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(() => setOpen(true), warm ? 0 : delay);
- };
- const hide = () => {
+ timer.current = setTimeout(
+ () => {
+ place();
+ setOpen(true);
+ },
+ warm ? 0 : delay,
+ );
+ }, [canHover, delay, place]);
+
+ const hide = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
- if (open) lastHiddenAt = Date.now();
- setOpen(false);
- };
+ setOpen((wasOpen) => {
+ if (wasOpen) lastHiddenAt = Date.now();
+ return false;
+ });
+ }, []);
- if (!isValidElement(children)) return children;
+ // 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 trigger = cloneElement(children as ReactElement>, {
- onMouseEnter: show,
- onMouseLeave: hide,
- onFocus: show,
- onBlur: hide,
- "aria-describedby": id,
- });
+ const variants = useMemo(
+ () => (reduce ? REDUCED_VARIANTS : buildVariants(side)),
+ [reduce, side],
+ );
- const variants = reduce ? REDUCED_VARIANTS : buildVariants(side);
+ if (!isValidElement(children)) return children;
+
+ const trigger = cloneElement(
+ children as ReactElement>,
+ {
+ onMouseEnter: show,
+ onMouseLeave: hide,
+ onFocus: show,
+ onBlur: hide,
+ "aria-describedby": id,
+ },
+ );
return (
-
- {trigger}
-
- {open ? (
-
-
- {content}
-
-
- ) : null}
-
-
+ <>
+
+ {trigger}
+
+ {typeof document !== "undefined"
+ ? createPortal(
+
+ {open && coords ? (
+
+
+ {content}
+
+
+ ) : null}
+ ,
+ document.body,
+ )
+ : null}
+ >
);
}
diff --git a/components/previews/blocks/availability-scheduler.preview.tsx b/components/previews/blocks/availability-scheduler.preview.tsx
new file mode 100644
index 0000000..59cc50d
--- /dev/null
+++ b/components/previews/blocks/availability-scheduler.preview.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import { AvailabilityScheduler } from "@/components/motion/availability-scheduler";
+
+export function AvailabilitySchedulerPreview() {
+ return (
+
+ );
+}
diff --git a/components/previews/index.tsx b/components/previews/index.tsx
index 0f4bff2..8462f61 100644
--- a/components/previews/index.tsx
+++ b/components/previews/index.tsx
@@ -42,6 +42,11 @@ export const previews: Record = {
"blocks/wallet-card": dynamic(() =>
import("./blocks/wallet-card.preview").then((m) => m.WalletCardPreview),
),
+ "blocks/availability-scheduler": dynamic(() =>
+ import("./blocks/availability-scheduler.preview").then(
+ (m) => m.AvailabilitySchedulerPreview,
+ ),
+ ),
"motion/table": dynamic(() =>
import("./motion/table.preview").then((m) => m.TablePreview),
),
@@ -154,6 +159,9 @@ export const previews: Record = {
"motion/popover": dynamic(() =>
import("./motion/popover.preview").then((m) => m.PopoverPreview),
),
+ "motion/popover-morph": dynamic(() =>
+ import("./motion/popover-morph.preview").then((m) => m.MorphPopoverPreview),
+ ),
"motion/morphing-modal": dynamic(() =>
import("./motion/morphing-modal.preview").then((m) => m.MorphingModalPreview),
),
diff --git a/components/previews/motion/popover-morph.preview.tsx b/components/previews/motion/popover-morph.preview.tsx
new file mode 100644
index 0000000..f9dcb2f
--- /dev/null
+++ b/components/previews/motion/popover-morph.preview.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { ChevronDown, Copy, Pencil, Share2, Trash2 } from "lucide-react";
+import { useState } from "react";
+import {
+ MorphPopover,
+ MorphPopoverContent,
+ MorphPopoverTrigger,
+} from "@/components/motion/popover-morph";
+
+const ACTIONS = [
+ { icon: Pencil, label: "Edit" },
+ { icon: Copy, label: "Duplicate" },
+ { icon: Share2, label: "Share" },
+ { icon: Trash2, label: "Delete" },
+];
+
+export function MorphPopoverPreview() {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+ Options
+
+
+
+
+
+ {ACTIONS.map(({ icon: Icon, label }) => (
+ setOpen(false)}
+ className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted"
+ >
+
+ {label}
+
+ ))}
+
+
+ );
+}
diff --git a/lib/registry.ts b/lib/registry.ts
index 414f647..868599d 100644
--- a/lib/registry.ts
+++ b/lib/registry.ts
@@ -193,7 +193,7 @@ export const registry: CategoryEntry[] = [
slug: "popover",
name: "Popover",
description:
- "Gooey popover whose panel oozes out of the trigger through an SVG goo filter — a liquid neck that stretches and pinches — with crisp content fading in on top. Click or hover trigger, controlled or uncontrolled.",
+ "Gooey popover whose panel oozes out of the trigger through an SVG goo filter — a liquid neck that stretches and pinches — with crisp content fading in on top, plus a Morph variant that clip-morphs open from the trigger corner. Click or hover trigger, controlled or uncontrolled.",
file: "components/motion/popover.tsx",
badge: "new",
launchedAt: "2026-07-07",
@@ -204,6 +204,30 @@ export const registry: CategoryEntry[] = [
"svg goo filter",
"metaball popover",
"hover popover react",
+ "morph popover react",
+ "dropdown menu react",
+ ],
+ examples: [
+ {
+ slug: "gooey",
+ name: "Gooey Popover",
+ description:
+ "Composable Popover, PopoverTrigger, PopoverContent; the panel oozes out of the trigger through an SVG goo filter with a liquid neck, crisp content fading in on top. Click or hover, controlled or uncontrolled.",
+ installSlug: "popover",
+ file: "components/motion/popover.tsx",
+ previewKey: "motion/popover",
+ previewFile: "components/previews/motion/popover.preview.tsx",
+ },
+ {
+ slug: "morph",
+ name: "Morph Popover",
+ description:
+ "Composable MorphPopover, MorphPopoverTrigger, MorphPopoverContent; the panel is laid out full size but clipped to the corner nearest the trigger, then unclips as one piece — a single-surface morph with a drop-shadow that hugs the shape. Side/align aware, controlled or uncontrolled.",
+ installSlug: "popover-morph",
+ file: "components/motion/popover-morph.tsx",
+ previewKey: "motion/popover-morph",
+ previewFile: "components/previews/motion/popover-morph.preview.tsx",
+ },
],
},
{
@@ -533,6 +557,22 @@ export const registry: CategoryEntry[] = [
name: "Blocks",
description: "Composed, product-ready widgets built from beUI motion primitives.",
components: [
+ {
+ slug: "availability-scheduler",
+ name: "Availability Scheduler",
+ description: "Weekly 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.",
+ file: "components/motion/availability-scheduler/index.tsx",
+ badge: "new",
+ launchedAt: "2026-07-10",
+ keywords: [
+ "availability scheduler react",
+ "weekly hours picker react",
+ "working hours component",
+ "cal.com availability component",
+ "time range picker react",
+ "business hours editor",
+ ],
+ },
{
slug: "swap",
name: "Multi-chain Swap",