Arc Picker
Radial text picker with top, bottom, left or right curves, smooth wheel movement and live selection on each scroll step. One persistent bracket frame grows and squeezes around each label. Supports drag momentum, keyboard input, disabled choices, controlled or uncontrolled values, forms and reduced motion.
Preview
Drag vertically, scroll or use the arrow keys.
"use client";
import { useState } from "react";
import { ArcPicker, type ArcPickerSide } from "@/components/motion/arc-picker";
import { Button } from "@/components/motion/button";
const SIDES = ["top", "bottom", "left", "right"] as const;
const OPTIONS = [
{ value: "first-light", label: "First light" },
{ value: "dawn", label: "Dawn" },
{ value: "daybreak", label: "Daybreak" },
{ value: "sunrise", label: "Sunrise" },
{ value: "early-morning", label: "Early morning" },
{ value: "morning", label: "Morning" },
{ value: "late-morning", label: "Late morning" },
{ value: "midday", label: "Midday" },
{ value: "noon", label: "Noon" },
{ value: "afternoon", label: "Afternoon" },
{ value: "golden-hour", label: "Golden hour" },
{ value: "sunset", label: "Sunset" },
{ value: "blue-hour", label: "Blue hour" },
{ value: "dusk", label: "Dusk" },
{ value: "twilight", label: "Twilight" },
{ value: "evening", label: "Evening" },
{ value: "nightfall", label: "Nightfall" },
{ value: "night", label: "Night" },
{ value: "late-night", label: "Late night" },
{ value: "midnight", label: "Midnight" },
{ value: "deep-night", label: "Deep night" },
{ value: "starlight", label: "Starlight" },
];
export function ArcPickerPreview() {
const [value, setValue] = useState("golden-hour");
const [side, setSide] = useState<ArcPickerSide>("right");
return (
<div className="w-full max-w-xl py-5">
<fieldset className="flex flex-wrap items-center justify-center gap-1 px-4">
<legend className="sr-only">Curve side</legend>
{SIDES.map((direction) => (
<Button
key={direction}
size="sm"
variant={side === direction ? "secondary" : "ghost"}
aria-pressed={side === direction}
onClick={() => setSide(direction)}
className="capitalize"
>
{direction}
</Button>
))}
</fieldset>
<div className="my-8 flex min-h-108 items-center sm:my-10">
<ArcPicker
options={OPTIONS}
value={value}
onValueChange={setValue}
aria-label="Time of day"
side={side}
radius={260}
visibleCount={9}
itemHeight={48}
/>
</div>
<p className="px-6 text-center text-xs text-muted-foreground">
Drag{" "}
{side === "top" || side === "bottom" ? "horizontally" : "vertically"},
scroll or use the arrow keys.
</p>
</div>
);
}
"use client";
// beui.dev/components/motion/arc-picker
import {
animate,
type AnimationPlaybackControls,
motion,
type MotionValue,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import {
type ComponentProps,
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { SPRING_GLIDE, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ArcPickerOption = {
value: string;
label: string;
disabled?: boolean;
};
export type ArcPickerSide = "top" | "bottom" | "left" | "right";
export type ArcPickerProps = Omit<
ComponentProps<"div">,
"children" | "defaultValue" | "onChange"
> & {
options: readonly ArcPickerOption[];
value?: string;
defaultValue?: string;
/** Called for each enabled choice crossed during scrolling, dragging and momentum. */
onValueChange?: (value: string) => void;
/** The side the arc bows toward. Top and bottom scroll horizontally. */
side?: ArcPickerSide;
/** Radius in pixels. Larger radii make a gentler curve. */
radius?: number;
/** Minimum spacing along the arc. Horizontal sides also make room for label widths. */
itemHeight?: number;
visibleCount?: number;
disabled?: boolean;
name?: string;
};
// A short velocity projection lets a flick coast through several detents
// before the shared layout spring settles it, without a long free-running loop.
const COAST_SECONDS = 0.18;
const WHEEL_SETTLE_MS = 120;
// Reserve room for the glyphs and their small outward movement without
// animating padding or changing any label's measured width.
const BRACKET_SPACE = 24;
const BRACKET_GAP = 4;
const BRACKET_EXPANSION = 6;
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
function ArcOption({
option,
index,
position,
verticalAnchor,
radius,
itemHeight,
visibleCount,
side,
selected,
disabled,
setRef,
onSelect,
}: {
option: ArcPickerOption;
index: number;
position: MotionValue<number>;
verticalAnchor: number | string;
radius: number;
itemHeight: number;
visibleCount: number;
side: ArcPickerSide;
selected: boolean;
disabled: boolean;
setRef: (element: HTMLButtonElement | null) => void;
onSelect: () => void;
}) {
const horizontal = side === "top" || side === "bottom";
const transform = useTransform(position, (at) => {
const angle = clamp(((index - at) * itemHeight) / radius, -1.5, 1.5);
const bend = radius * (1 - Math.cos(angle));
const along = radius * Math.sin(angle);
const x = horizontal ? along : side === "left" ? bend : -bend;
const y = horizontal ? (side === "top" ? bend : -bend) : along;
const rotation =
((side === "left" || side === "bottom" ? -angle : angle) * 180) / Math.PI;
const anchor = horizontal ? "-50%" : side === "left" ? "-100%" : "0%";
return `translate3d(${x}px, ${y}px, 0) translate(${anchor}, -50%) rotate(${rotation}deg)`;
});
const opacity = useTransform(position, (at) => {
const distance = Math.abs(index - at);
return (
clamp((visibleCount / 2 + 0.5 - distance) / 1.5, 0, 1) *
(disabled || option.disabled ? 0.35 : 1)
);
});
const pointerEvents = useTransform(position, (at) =>
Math.abs(index - at) < visibleCount / 2 ? "auto" : "none",
);
return (
<motion.button
ref={setRef}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled || option.disabled}
tabIndex={selected && !disabled && !option.disabled ? 0 : -1}
data-slot="arc-picker-option"
data-value={option.value}
data-selected={selected || undefined}
onClick={(event) => {
if (!event.defaultPrevented) onSelect();
}}
className={cn(
"absolute rounded-md px-2 py-1 text-2xl font-light whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-35",
horizontal && "text-center",
!horizontal && (side === "left" ? "text-right" : "text-left"),
selected ? "text-primary" : "text-muted-foreground",
)}
style={{
top: horizontal ? (side === "top" ? 36 : "calc(100% - 36px)") : "50%",
left: horizontal ? "50%" : verticalAnchor,
transformOrigin: horizontal
? "center"
: side === "left"
? "right center"
: "left center",
maxWidth: horizontal ? "calc(100% - 4rem)" : "calc(100% - 3rem)",
transform,
opacity,
pointerEvents,
}}
>
<span
data-slot="arc-picker-label"
className="block overflow-hidden text-ellipsis"
>
{option.label}
</span>
</motion.button>
);
}
function ArcSelectionFrame({
width,
expansion,
side,
verticalAnchor,
visible,
}: {
width: MotionValue<number>;
expansion: MotionValue<number>;
side: ArcPickerSide;
verticalAnchor: number | string;
visible: boolean;
}) {
const horizontal = side === "top" || side === "bottom";
const leading = useTransform<number, string>(
[width, expansion],
([size = 0, amount = 0]) => {
const extent = horizontal ? size / 2 : side === "left" ? size : 0;
const gap = BRACKET_GAP + clamp(amount, 0, 1) * BRACKET_EXPANSION;
return `translate3d(${-extent - gap}px, -50%, 0) scaleY(${1 - clamp(amount, 0, 1) * 0.08})`;
},
);
const trailing = useTransform<number, string>(
[width, expansion],
([size = 0, amount = 0]) => {
const extent = horizontal ? size / 2 : side === "right" ? size : 0;
const gap = BRACKET_GAP + clamp(amount, 0, 1) * BRACKET_EXPANSION;
return `translate3d(${extent + gap}px, -50%, 0) scaleY(${1 - clamp(amount, 0, 1) * 0.08})`;
},
);
const opacity = useTransform(width, (size) => (size > 0 ? 0.85 : 0));
return (
<div
aria-hidden="true"
data-slot="arc-picker-selection-frame"
className="pointer-events-none absolute h-0 w-0 text-2xl font-light text-primary"
style={{
top: horizontal ? (side === "top" ? 36 : "calc(100% - 36px)") : "50%",
left: horizontal ? "50%" : verticalAnchor,
visibility: visible ? undefined : "hidden",
}}
>
<motion.span
data-slot="arc-picker-bracket"
data-edge="leading"
className="absolute right-0"
style={{ transform: leading, opacity }}
>
[
</motion.span>
<motion.span
data-slot="arc-picker-bracket"
data-edge="trailing"
className="absolute left-0"
style={{ transform: trailing, opacity }}
>
]
</motion.span>
</div>
);
}
/** A radial single-choice picker with drag momentum and native button controls. */
export function ArcPicker({
options,
value,
defaultValue,
onValueChange,
side = "right",
radius = 280,
itemHeight = 48,
visibleCount = 7,
disabled = false,
name,
className,
style,
onKeyDown,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture,
onFocusCapture,
onBlurCapture,
onClickCapture,
"aria-label": label = "Choose an option",
"aria-describedby": describedBy,
...props
}: ArcPickerProps) {
const reducedMotion = useReducedMotion();
const horizontal = side === "top" || side === "bottom";
const instructionsId = useId();
const root = useRef<HTMLDivElement>(null);
const buttons = useRef(new Map<string, HTMLButtonElement>());
const first = options.find((option) => !option.disabled)?.value;
const [internalValue, setInternalValue] = useState(defaultValue ?? first);
const controlled = value !== undefined;
const candidate = controlled ? value : internalValue;
const selectedValue = options.some(
(option) => option.value === candidate && !option.disabled,
)
? candidate
: first;
// Resolve identity in the same render as changed options. A removed choice
// cannot reappear later and silently restore an abandoned selection.
if (!controlled && internalValue !== selectedValue)
setInternalValue(selectedValue);
const selectedIndex = Math.max(
0,
options.findIndex((option) => option.value === selectedValue),
);
const safeRadius = Math.max(120, Number.isFinite(radius) ? radius : 280);
const rowHeight = Math.max(32, Number.isFinite(itemHeight) ? itemHeight : 48);
const count = Math.max(
3,
Number.isFinite(visibleCount) ? Math.floor(visibleCount) : 7,
);
const [measurements, setMeasurements] = useState({ width: 0, labelWidth: 0 });
const labelsKey = JSON.stringify(options.map((option) => option.label));
const spacing = horizontal
? Math.max(rowHeight, measurements.labelWidth + BRACKET_SPACE * 2)
: rowHeight;
const verticalInset = Math.max(
BRACKET_SPACE,
Math.min(
measurements.width * 0.42,
measurements.width - measurements.labelWidth - BRACKET_SPACE,
),
);
const verticalAnchor = measurements.width
? side === "left"
? measurements.width - verticalInset
: verticalInset
: side === "left"
? "58%"
: "42%";
const curveRadius = horizontal
? Math.max(safeRadius, spacing * 2)
: safeRadius;
const shownCount = horizontal
? Math.min(
count,
Math.max(1, (measurements.width || count * spacing) / spacing),
)
: count;
const horizontalHeight = Math.ceil(
Math.max(
rowHeight * 2,
curveRadius *
(1 -
Math.cos(
Math.min(1.35, (shownCount * spacing) / (2 * curveRadius)),
)) +
rowHeight +
24,
),
);
const position = useMotionValue(selectedIndex);
const activity = useMotionValue(0);
const expansion = useSpring(activity, SPRING_GLIDE);
const frameTarget = useMotionValue(0);
const frameWidth = useSpring(frameTarget, SPRING_LAYOUT);
const frameMeasured = useRef(false);
const animation = useRef<AnimationPlaybackControls | null>(null);
const wheelTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const wheelTarget = useRef<number | null>(null);
const reconcileFrame = useRef(0);
const request = useRef(selectedValue);
const tracking = useRef(false);
const instant = useRef(false);
const drag = useRef<{
id: number;
coordinate: number;
position: number;
lastCoordinate: number;
time: number;
velocity: number;
moved: boolean;
} | null>(null);
const suppressClick = useRef(false);
const focusWithin = useRef(false);
const optionsKey = JSON.stringify(
options.map((option) => [option.value, !!option.disabled]),
);
const configuration = JSON.stringify([
optionsKey,
side,
spacing,
disabled,
!!reducedMotion,
]);
const previousConfiguration = useRef(configuration);
const latest = useRef({
options,
selectedIndex,
selectedValue,
controlled,
disabled,
reducedMotion,
spacing,
horizontal,
onValueChange,
});
useLayoutEffect(() => {
latest.current = {
options,
selectedIndex,
selectedValue,
controlled,
disabled,
reducedMotion,
spacing,
horizontal,
onValueChange,
};
});
const measureFrame = useCallback(() => {
const current = latest.current;
const width =
buttons.current.get(current.selectedValue ?? "")?.offsetWidth ?? 0;
frameTarget.set(width);
// The first measurement and keyboard changes are immediate. Pointer
// selections retarget the same two glyphs, preserving spring velocity.
if (!frameMeasured.current || instant.current || current.reducedMotion)
frameWidth.jump(width);
frameMeasured.current = true;
}, [frameTarget, frameWidth]);
useLayoutEffect(() => {
// Observe untransformed sizes: animated bounds would change the spacing
// every frame. Font loading and responsive text sizes still trigger this.
void optionsKey;
void labelsKey;
const element = root.current;
if (!element) return;
const measure = () => {
const width = element.clientWidth;
const labelWidth = Math.max(
0,
...Array.from(buttons.current.values(), (button) => button.offsetWidth),
);
setMeasurements((previous) =>
previous.width === width && previous.labelWidth === labelWidth
? previous
: { width, labelWidth },
);
// A font or truncated label can resize while the largest label stays
// the same width. Update the frame even when React needs no new measure.
measureFrame();
};
const observer = new ResizeObserver(measure);
observer.observe(element);
for (const button of buttons.current.values()) observer.observe(button);
measure();
return () => observer.disconnect();
}, [optionsKey, labelsKey, measureFrame]);
useLayoutEffect(() => {
void selectedValue;
void reducedMotion;
measureFrame();
}, [selectedValue, reducedMotion, measureFrame]);
const stop = useCallback(() => {
animation.current?.stop();
animation.current = null;
if (wheelTimer.current) clearTimeout(wheelTimer.current);
wheelTimer.current = null;
wheelTarget.current = null;
cancelAnimationFrame(reconcileFrame.current);
tracking.current = false;
activity.set(0);
if (latest.current.reducedMotion) expansion.jump(0);
const pointer = drag.current;
drag.current = null;
if (pointer && root.current?.hasPointerCapture(pointer.id))
root.current.releasePointerCapture(pointer.id);
}, [activity, expansion]);
const settle = useCallback(
(index: number, immediate = false) => {
animation.current?.stop();
const complete = () => {
tracking.current = false;
animation.current = null;
wheelTarget.current = null;
activity.set(0);
};
if (immediate || latest.current.reducedMotion) {
position.jump(index);
complete();
} else
animation.current = animate(position, index, {
...SPRING_LAYOUT,
onComplete: complete,
});
},
[activity, position],
);
useLayoutEffect(() => {
const changed = previousConfiguration.current !== configuration;
previousConfiguration.current = configuration;
// An accepted live selection must not cancel the gesture or snap its
// floating position. External changes still own the control immediately.
if (changed || !tracking.current || selectedValue !== request.current) {
stop();
request.current = selectedValue;
settle(selectedIndex, instant.current || disabled || !!reducedMotion);
}
if (changed) {
suppressClick.current = false;
}
instant.current = false;
if (focusWithin.current && selectedValue !== undefined && !disabled) {
buttons.current.get(selectedValue)?.focus({ preventScroll: true });
}
}, [
selectedValue,
selectedIndex,
configuration,
disabled,
reducedMotion,
settle,
stop,
]);
const publish = useCallback(
(index: number, keyboard = false) => {
const current = latest.current;
const option = current.options[index];
if (!option || option.disabled || current.disabled) return;
if (option.value === request.current) return;
request.current = option.value;
if (!current.controlled) setInternalValue(option.value);
current.onValueChange?.(option.value);
if (!current.controlled) return;
cancelAnimationFrame(reconcileFrame.current);
// Give React a full frame to commit the consumer's update before checking
// for rejection. A same-frame RAF can run before that commit and cancel
// an accepted gesture, especially when a detent is crossed inside a RAF.
reconcileFrame.current = requestAnimationFrame(() => {
reconcileFrame.current = requestAnimationFrame(() => {
if (
request.current !== option.value ||
latest.current.selectedValue === option.value
)
return;
stop();
request.current = latest.current.selectedValue;
settle(latest.current.selectedIndex, keyboard);
if (
(keyboard || focusWithin.current) &&
latest.current.selectedValue !== undefined
) {
buttons.current
.get(latest.current.selectedValue)
?.focus({ preventScroll: true });
}
});
});
},
[settle, stop],
);
const select = useCallback(
(index: number, keyboard = false) => {
const current = latest.current;
const option = current.options[index];
if (!option || option.disabled || current.disabled) return;
stop();
instant.current = keyboard;
if (keyboard) {
expansion.jump(0);
frameWidth.jump(frameTarget.get());
}
publish(index, keyboard);
settle(index, keyboard);
if (keyboard)
buttons.current.get(option.value)?.focus({ preventScroll: true });
},
[expansion, frameTarget, frameWidth, publish, settle, stop],
);
const nearestEnabled = useCallback((at: number) => {
let nearest = -1;
let distance = Infinity;
latest.current.options.forEach((option, index) => {
if (!option.disabled && Math.abs(index - at) < distance) {
nearest = index;
distance = Math.abs(index - at);
}
});
return nearest;
}, []);
useEffect(
() =>
position.on("change", (at) => {
if (!tracking.current || latest.current.disabled) return;
const next = nearestEnabled(at);
if (next < 0) return;
const previous = latest.current.options.findIndex(
(option) => option.value === request.current,
);
// A large frame delta may cross several detents. Notify each enabled
// choice in order; never wait for the wheel-idle timer or pointer release.
const step = next > previous ? 1 : -1;
if (previous < 0 || previous === next) publish(next);
else
for (
let index = previous + step;
step > 0 ? index <= next : index >= next;
index += step
)
publish(index);
}),
[nearestEnabled, position, publish],
);
useEffect(() => {
const element = root.current;
if (!element) return;
const wheel = (event: WheelEvent) => {
const current = latest.current;
if (
current.disabled ||
event.ctrlKey ||
drag.current?.moved ||
!current.options.some((option) => !option.disabled)
)
return;
const useX = Math.abs(event.deltaX) > Math.abs(event.deltaY);
if (!current.horizontal && useX) return;
const delta =
(current.horizontal && useX ? event.deltaX : event.deltaY) *
(event.deltaMode === 1
? 16
: event.deltaMode === 2
? current.horizontal
? element.clientWidth
: element.clientHeight
: 1);
if (!delta) return;
const enabled = current.options
.map((option, index) => ({ option, index }))
.filter(({ option }) => !option.disabled);
const first = enabled[0]?.index ?? 0;
const last = enabled[enabled.length - 1]?.index ?? first;
const from = wheelTarget.current ?? position.get();
// At an end, release wheel events back to the page's own scroll.
if ((from <= first && delta < 0) || (from >= last && delta > 0)) return;
event.preventDefault();
if (wheelTimer.current) clearTimeout(wheelTimer.current);
if (current.reducedMotion) {
const at = enabled.findIndex(
({ option }) => option.value === request.current,
);
const next =
enabled[clamp(at + Math.sign(delta), 0, enabled.length - 1)];
if (next) select(next.index, true);
} else {
if (wheelTarget.current === null) {
animation.current?.stop();
animation.current = null;
}
instant.current = false;
tracking.current = true;
activity.set(1);
const next = clamp(from + delta / current.spacing, first, last);
// Wheel deltas already contain trackpad momentum. Follow them directly;
// useTransform coalesces visual writes to one per frame. A spring here
// lags behind frequent input and can feel stuck until scrolling stops.
wheelTarget.current = next;
position.set(next);
wheelTimer.current = setTimeout(() => {
wheelTimer.current = null;
wheelTarget.current = null;
settle(nearestEnabled(next));
}, WHEEL_SETTLE_MS);
}
};
element.addEventListener("wheel", wheel, { passive: false });
return () => {
element.removeEventListener("wheel", wheel);
stop();
};
}, [activity, nearestEnabled, position, select, settle, stop]);
const keyboard = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
const enabled = options
.map((option, index) => ({ option, index }))
.filter(({ option }) => !option.disabled);
if (!enabled.length) return;
const at = Math.max(
0,
enabled.findIndex(({ option }) => option.value === request.current),
);
let next: number;
switch (event.key) {
case "ArrowDown":
case "ArrowRight":
next = at + (event.shiftKey ? 5 : 1);
break;
case "ArrowUp":
case "ArrowLeft":
next = at - (event.shiftKey ? 5 : 1);
break;
case "Home":
next = 0;
break;
case "End":
next = enabled.length - 1;
break;
case "PageDown":
next = at + 5;
break;
case "PageUp":
next = at - 5;
break;
default:
return;
}
event.preventDefault();
const option = enabled[clamp(next, 0, enabled.length - 1)];
if (option) select(option.index, true);
};
const release = (event: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
const current = drag.current;
if (!current || current.id !== event.pointerId) return;
drag.current = null;
if (root.current?.hasPointerCapture(event.pointerId))
root.current.releasePointerCapture(event.pointerId);
if (!current.moved) return;
suppressClick.current = true;
if (cancelled) {
tracking.current = false;
activity.set(0);
settle(latest.current.selectedIndex);
} else {
const velocity =
performance.now() - current.time > 100 ? 0 : current.velocity;
settle(
nearestEnabled(
position.get() + (reducedMotion ? 0 : velocity * COAST_SECONDS),
),
);
}
};
return (
<div
{...props}
ref={root}
role="radiogroup"
aria-label={label}
aria-describedby={[describedBy, instructionsId].filter(Boolean).join(" ")}
aria-disabled={disabled || undefined}
aria-orientation={horizontal ? "horizontal" : "vertical"}
data-slot="arc-picker"
data-side={side}
className={cn(
"relative w-full min-w-0 cursor-grab select-none overflow-hidden active:cursor-grabbing",
disabled && "cursor-default opacity-50",
className,
)}
style={{
touchAction: horizontal ? "pan-y pinch-zoom" : "pan-x pinch-zoom",
height: horizontal ? horizontalHeight : count * rowHeight,
maskImage: `linear-gradient(to ${horizontal ? "right" : "bottom"}, transparent, black 12%, black 88%, transparent)`,
...style,
}}
onKeyDown={keyboard}
onFocusCapture={(event) => {
focusWithin.current = true;
onFocusCapture?.(event);
}}
onBlurCapture={(event) => {
if (event.relatedTarget && !root.current?.contains(event.relatedTarget))
focusWithin.current = false;
onBlurCapture?.(event);
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (
event.defaultPrevented ||
disabled ||
selectedValue === undefined ||
event.button !== 0 ||
!event.isPrimary
)
return;
stop();
suppressClick.current = false;
const coordinate = horizontal ? event.clientX : event.clientY;
drag.current = {
id: event.pointerId,
coordinate,
position: position.get(),
lastCoordinate: coordinate,
time: performance.now(),
velocity: 0,
moved: false,
};
}}
onPointerMove={(event) => {
onPointerMove?.(event);
const current = drag.current;
if (
event.defaultPrevented ||
!current ||
current.id !== event.pointerId
)
return;
const coordinate = horizontal ? event.clientX : event.clientY;
const delta = coordinate - current.coordinate;
if (!current.moved && Math.abs(delta) < 6) return;
current.moved = true;
tracking.current = true;
instant.current = false;
activity.set(reducedMotion ? 0 : 1);
root.current?.setPointerCapture(event.pointerId);
const now = performance.now();
const elapsed = Math.max(8, now - current.time);
current.velocity =
current.velocity * 0.35 +
((current.lastCoordinate - coordinate) / spacing / elapsed) *
1000 *
0.65;
current.lastCoordinate = coordinate;
current.time = now;
const next = clamp(
current.position - delta / spacing,
0,
Math.max(0, options.length - 1),
);
position.set(reducedMotion ? Math.round(next) : next);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
release(event, false);
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
release(event, true);
}}
onLostPointerCapture={(event) => {
onLostPointerCapture?.(event);
release(event, true);
}}
onClickCapture={(event) => {
onClickCapture?.(event);
if (suppressClick.current && event.detail > 0) {
event.preventDefault();
event.stopPropagation();
suppressClick.current = false;
}
}}
>
<span id={instructionsId} className="sr-only">
Drag {horizontal ? "horizontally" : "vertically"} or scroll to choose.
Use arrow keys to move, Home and End to jump.
</span>
{name && (
<input
type="hidden"
name={name}
value={selectedValue ?? ""}
disabled={disabled}
/>
)}
{options.map((option, index) => (
<ArcOption
key={option.value}
option={option}
index={index}
position={position}
verticalAnchor={verticalAnchor}
radius={curveRadius}
itemHeight={spacing}
visibleCount={shownCount}
side={side}
selected={option.value === selectedValue}
disabled={disabled}
setRef={(element) => {
if (element) buttons.current.set(option.value, element);
else buttons.current.delete(option.value);
}}
onSelect={() => select(index)}
/>
))}
<ArcSelectionFrame
width={frameWidth}
expansion={expansion}
side={side}
verticalAnchor={verticalAnchor}
visible={selectedValue !== undefined}
/>
{!options.length && (
<span className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
No options
</span>
)}
</div>
);
}
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
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
"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/motion/arc-picker
import {
animate,
type AnimationPlaybackControls,
motion,
type MotionValue,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import {
type ComponentProps,
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { SPRING_GLIDE, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type ArcPickerOption = {
value: string;
label: string;
disabled?: boolean;
};
export type ArcPickerSide = "top" | "bottom" | "left" | "right";
export type ArcPickerProps = Omit<
ComponentProps<"div">,
"children" | "defaultValue" | "onChange"
> & {
options: readonly ArcPickerOption[];
value?: string;
defaultValue?: string;
/** Called for each enabled choice crossed during scrolling, dragging and momentum. */
onValueChange?: (value: string) => void;
/** The side the arc bows toward. Top and bottom scroll horizontally. */
side?: ArcPickerSide;
/** Radius in pixels. Larger radii make a gentler curve. */
radius?: number;
/** Minimum spacing along the arc. Horizontal sides also make room for label widths. */
itemHeight?: number;
visibleCount?: number;
disabled?: boolean;
name?: string;
};
// A short velocity projection lets a flick coast through several detents
// before the shared layout spring settles it, without a long free-running loop.
const COAST_SECONDS = 0.18;
const WHEEL_SETTLE_MS = 120;
// Reserve room for the glyphs and their small outward movement without
// animating padding or changing any label's measured width.
const BRACKET_SPACE = 24;
const BRACKET_GAP = 4;
const BRACKET_EXPANSION = 6;
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
function ArcOption({
option,
index,
position,
verticalAnchor,
radius,
itemHeight,
visibleCount,
side,
selected,
disabled,
setRef,
onSelect,
}: {
option: ArcPickerOption;
index: number;
position: MotionValue<number>;
verticalAnchor: number | string;
radius: number;
itemHeight: number;
visibleCount: number;
side: ArcPickerSide;
selected: boolean;
disabled: boolean;
setRef: (element: HTMLButtonElement | null) => void;
onSelect: () => void;
}) {
const horizontal = side === "top" || side === "bottom";
const transform = useTransform(position, (at) => {
const angle = clamp(((index - at) * itemHeight) / radius, -1.5, 1.5);
const bend = radius * (1 - Math.cos(angle));
const along = radius * Math.sin(angle);
const x = horizontal ? along : side === "left" ? bend : -bend;
const y = horizontal ? (side === "top" ? bend : -bend) : along;
const rotation =
((side === "left" || side === "bottom" ? -angle : angle) * 180) / Math.PI;
const anchor = horizontal ? "-50%" : side === "left" ? "-100%" : "0%";
return `translate3d(${x}px, ${y}px, 0) translate(${anchor}, -50%) rotate(${rotation}deg)`;
});
const opacity = useTransform(position, (at) => {
const distance = Math.abs(index - at);
return (
clamp((visibleCount / 2 + 0.5 - distance) / 1.5, 0, 1) *
(disabled || option.disabled ? 0.35 : 1)
);
});
const pointerEvents = useTransform(position, (at) =>
Math.abs(index - at) < visibleCount / 2 ? "auto" : "none",
);
return (
<motion.button
ref={setRef}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled || option.disabled}
tabIndex={selected && !disabled && !option.disabled ? 0 : -1}
data-slot="arc-picker-option"
data-value={option.value}
data-selected={selected || undefined}
onClick={(event) => {
if (!event.defaultPrevented) onSelect();
}}
className={cn(
"absolute rounded-md px-2 py-1 text-2xl font-light whitespace-nowrap outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-35",
horizontal && "text-center",
!horizontal && (side === "left" ? "text-right" : "text-left"),
selected ? "text-primary" : "text-muted-foreground",
)}
style={{
top: horizontal ? (side === "top" ? 36 : "calc(100% - 36px)") : "50%",
left: horizontal ? "50%" : verticalAnchor,
transformOrigin: horizontal
? "center"
: side === "left"
? "right center"
: "left center",
maxWidth: horizontal ? "calc(100% - 4rem)" : "calc(100% - 3rem)",
transform,
opacity,
pointerEvents,
}}
>
<span
data-slot="arc-picker-label"
className="block overflow-hidden text-ellipsis"
>
{option.label}
</span>
</motion.button>
);
}
function ArcSelectionFrame({
width,
expansion,
side,
verticalAnchor,
visible,
}: {
width: MotionValue<number>;
expansion: MotionValue<number>;
side: ArcPickerSide;
verticalAnchor: number | string;
visible: boolean;
}) {
const horizontal = side === "top" || side === "bottom";
const leading = useTransform<number, string>(
[width, expansion],
([size = 0, amount = 0]) => {
const extent = horizontal ? size / 2 : side === "left" ? size : 0;
const gap = BRACKET_GAP + clamp(amount, 0, 1) * BRACKET_EXPANSION;
return `translate3d(${-extent - gap}px, -50%, 0) scaleY(${1 - clamp(amount, 0, 1) * 0.08})`;
},
);
const trailing = useTransform<number, string>(
[width, expansion],
([size = 0, amount = 0]) => {
const extent = horizontal ? size / 2 : side === "right" ? size : 0;
const gap = BRACKET_GAP + clamp(amount, 0, 1) * BRACKET_EXPANSION;
return `translate3d(${extent + gap}px, -50%, 0) scaleY(${1 - clamp(amount, 0, 1) * 0.08})`;
},
);
const opacity = useTransform(width, (size) => (size > 0 ? 0.85 : 0));
return (
<div
aria-hidden="true"
data-slot="arc-picker-selection-frame"
className="pointer-events-none absolute h-0 w-0 text-2xl font-light text-primary"
style={{
top: horizontal ? (side === "top" ? 36 : "calc(100% - 36px)") : "50%",
left: horizontal ? "50%" : verticalAnchor,
visibility: visible ? undefined : "hidden",
}}
>
<motion.span
data-slot="arc-picker-bracket"
data-edge="leading"
className="absolute right-0"
style={{ transform: leading, opacity }}
>
[
</motion.span>
<motion.span
data-slot="arc-picker-bracket"
data-edge="trailing"
className="absolute left-0"
style={{ transform: trailing, opacity }}
>
]
</motion.span>
</div>
);
}
/** A radial single-choice picker with drag momentum and native button controls. */
export function ArcPicker({
options,
value,
defaultValue,
onValueChange,
side = "right",
radius = 280,
itemHeight = 48,
visibleCount = 7,
disabled = false,
name,
className,
style,
onKeyDown,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture,
onFocusCapture,
onBlurCapture,
onClickCapture,
"aria-label": label = "Choose an option",
"aria-describedby": describedBy,
...props
}: ArcPickerProps) {
const reducedMotion = useReducedMotion();
const horizontal = side === "top" || side === "bottom";
const instructionsId = useId();
const root = useRef<HTMLDivElement>(null);
const buttons = useRef(new Map<string, HTMLButtonElement>());
const first = options.find((option) => !option.disabled)?.value;
const [internalValue, setInternalValue] = useState(defaultValue ?? first);
const controlled = value !== undefined;
const candidate = controlled ? value : internalValue;
const selectedValue = options.some(
(option) => option.value === candidate && !option.disabled,
)
? candidate
: first;
// Resolve identity in the same render as changed options. A removed choice
// cannot reappear later and silently restore an abandoned selection.
if (!controlled && internalValue !== selectedValue)
setInternalValue(selectedValue);
const selectedIndex = Math.max(
0,
options.findIndex((option) => option.value === selectedValue),
);
const safeRadius = Math.max(120, Number.isFinite(radius) ? radius : 280);
const rowHeight = Math.max(32, Number.isFinite(itemHeight) ? itemHeight : 48);
const count = Math.max(
3,
Number.isFinite(visibleCount) ? Math.floor(visibleCount) : 7,
);
const [measurements, setMeasurements] = useState({ width: 0, labelWidth: 0 });
const labelsKey = JSON.stringify(options.map((option) => option.label));
const spacing = horizontal
? Math.max(rowHeight, measurements.labelWidth + BRACKET_SPACE * 2)
: rowHeight;
const verticalInset = Math.max(
BRACKET_SPACE,
Math.min(
measurements.width * 0.42,
measurements.width - measurements.labelWidth - BRACKET_SPACE,
),
);
const verticalAnchor = measurements.width
? side === "left"
? measurements.width - verticalInset
: verticalInset
: side === "left"
? "58%"
: "42%";
const curveRadius = horizontal
? Math.max(safeRadius, spacing * 2)
: safeRadius;
const shownCount = horizontal
? Math.min(
count,
Math.max(1, (measurements.width || count * spacing) / spacing),
)
: count;
const horizontalHeight = Math.ceil(
Math.max(
rowHeight * 2,
curveRadius *
(1 -
Math.cos(
Math.min(1.35, (shownCount * spacing) / (2 * curveRadius)),
)) +
rowHeight +
24,
),
);
const position = useMotionValue(selectedIndex);
const activity = useMotionValue(0);
const expansion = useSpring(activity, SPRING_GLIDE);
const frameTarget = useMotionValue(0);
const frameWidth = useSpring(frameTarget, SPRING_LAYOUT);
const frameMeasured = useRef(false);
const animation = useRef<AnimationPlaybackControls | null>(null);
const wheelTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const wheelTarget = useRef<number | null>(null);
const reconcileFrame = useRef(0);
const request = useRef(selectedValue);
const tracking = useRef(false);
const instant = useRef(false);
const drag = useRef<{
id: number;
coordinate: number;
position: number;
lastCoordinate: number;
time: number;
velocity: number;
moved: boolean;
} | null>(null);
const suppressClick = useRef(false);
const focusWithin = useRef(false);
const optionsKey = JSON.stringify(
options.map((option) => [option.value, !!option.disabled]),
);
const configuration = JSON.stringify([
optionsKey,
side,
spacing,
disabled,
!!reducedMotion,
]);
const previousConfiguration = useRef(configuration);
const latest = useRef({
options,
selectedIndex,
selectedValue,
controlled,
disabled,
reducedMotion,
spacing,
horizontal,
onValueChange,
});
useLayoutEffect(() => {
latest.current = {
options,
selectedIndex,
selectedValue,
controlled,
disabled,
reducedMotion,
spacing,
horizontal,
onValueChange,
};
});
const measureFrame = useCallback(() => {
const current = latest.current;
const width =
buttons.current.get(current.selectedValue ?? "")?.offsetWidth ?? 0;
frameTarget.set(width);
// The first measurement and keyboard changes are immediate. Pointer
// selections retarget the same two glyphs, preserving spring velocity.
if (!frameMeasured.current || instant.current || current.reducedMotion)
frameWidth.jump(width);
frameMeasured.current = true;
}, [frameTarget, frameWidth]);
useLayoutEffect(() => {
// Observe untransformed sizes: animated bounds would change the spacing
// every frame. Font loading and responsive text sizes still trigger this.
void optionsKey;
void labelsKey;
const element = root.current;
if (!element) return;
const measure = () => {
const width = element.clientWidth;
const labelWidth = Math.max(
0,
...Array.from(buttons.current.values(), (button) => button.offsetWidth),
);
setMeasurements((previous) =>
previous.width === width && previous.labelWidth === labelWidth
? previous
: { width, labelWidth },
);
// A font or truncated label can resize while the largest label stays
// the same width. Update the frame even when React needs no new measure.
measureFrame();
};
const observer = new ResizeObserver(measure);
observer.observe(element);
for (const button of buttons.current.values()) observer.observe(button);
measure();
return () => observer.disconnect();
}, [optionsKey, labelsKey, measureFrame]);
useLayoutEffect(() => {
void selectedValue;
void reducedMotion;
measureFrame();
}, [selectedValue, reducedMotion, measureFrame]);
const stop = useCallback(() => {
animation.current?.stop();
animation.current = null;
if (wheelTimer.current) clearTimeout(wheelTimer.current);
wheelTimer.current = null;
wheelTarget.current = null;
cancelAnimationFrame(reconcileFrame.current);
tracking.current = false;
activity.set(0);
if (latest.current.reducedMotion) expansion.jump(0);
const pointer = drag.current;
drag.current = null;
if (pointer && root.current?.hasPointerCapture(pointer.id))
root.current.releasePointerCapture(pointer.id);
}, [activity, expansion]);
const settle = useCallback(
(index: number, immediate = false) => {
animation.current?.stop();
const complete = () => {
tracking.current = false;
animation.current = null;
wheelTarget.current = null;
activity.set(0);
};
if (immediate || latest.current.reducedMotion) {
position.jump(index);
complete();
} else
animation.current = animate(position, index, {
...SPRING_LAYOUT,
onComplete: complete,
});
},
[activity, position],
);
useLayoutEffect(() => {
const changed = previousConfiguration.current !== configuration;
previousConfiguration.current = configuration;
// An accepted live selection must not cancel the gesture or snap its
// floating position. External changes still own the control immediately.
if (changed || !tracking.current || selectedValue !== request.current) {
stop();
request.current = selectedValue;
settle(selectedIndex, instant.current || disabled || !!reducedMotion);
}
if (changed) {
suppressClick.current = false;
}
instant.current = false;
if (focusWithin.current && selectedValue !== undefined && !disabled) {
buttons.current.get(selectedValue)?.focus({ preventScroll: true });
}
}, [
selectedValue,
selectedIndex,
configuration,
disabled,
reducedMotion,
settle,
stop,
]);
const publish = useCallback(
(index: number, keyboard = false) => {
const current = latest.current;
const option = current.options[index];
if (!option || option.disabled || current.disabled) return;
if (option.value === request.current) return;
request.current = option.value;
if (!current.controlled) setInternalValue(option.value);
current.onValueChange?.(option.value);
if (!current.controlled) return;
cancelAnimationFrame(reconcileFrame.current);
// Give React a full frame to commit the consumer's update before checking
// for rejection. A same-frame RAF can run before that commit and cancel
// an accepted gesture, especially when a detent is crossed inside a RAF.
reconcileFrame.current = requestAnimationFrame(() => {
reconcileFrame.current = requestAnimationFrame(() => {
if (
request.current !== option.value ||
latest.current.selectedValue === option.value
)
return;
stop();
request.current = latest.current.selectedValue;
settle(latest.current.selectedIndex, keyboard);
if (
(keyboard || focusWithin.current) &&
latest.current.selectedValue !== undefined
) {
buttons.current
.get(latest.current.selectedValue)
?.focus({ preventScroll: true });
}
});
});
},
[settle, stop],
);
const select = useCallback(
(index: number, keyboard = false) => {
const current = latest.current;
const option = current.options[index];
if (!option || option.disabled || current.disabled) return;
stop();
instant.current = keyboard;
if (keyboard) {
expansion.jump(0);
frameWidth.jump(frameTarget.get());
}
publish(index, keyboard);
settle(index, keyboard);
if (keyboard)
buttons.current.get(option.value)?.focus({ preventScroll: true });
},
[expansion, frameTarget, frameWidth, publish, settle, stop],
);
const nearestEnabled = useCallback((at: number) => {
let nearest = -1;
let distance = Infinity;
latest.current.options.forEach((option, index) => {
if (!option.disabled && Math.abs(index - at) < distance) {
nearest = index;
distance = Math.abs(index - at);
}
});
return nearest;
}, []);
useEffect(
() =>
position.on("change", (at) => {
if (!tracking.current || latest.current.disabled) return;
const next = nearestEnabled(at);
if (next < 0) return;
const previous = latest.current.options.findIndex(
(option) => option.value === request.current,
);
// A large frame delta may cross several detents. Notify each enabled
// choice in order; never wait for the wheel-idle timer or pointer release.
const step = next > previous ? 1 : -1;
if (previous < 0 || previous === next) publish(next);
else
for (
let index = previous + step;
step > 0 ? index <= next : index >= next;
index += step
)
publish(index);
}),
[nearestEnabled, position, publish],
);
useEffect(() => {
const element = root.current;
if (!element) return;
const wheel = (event: WheelEvent) => {
const current = latest.current;
if (
current.disabled ||
event.ctrlKey ||
drag.current?.moved ||
!current.options.some((option) => !option.disabled)
)
return;
const useX = Math.abs(event.deltaX) > Math.abs(event.deltaY);
if (!current.horizontal && useX) return;
const delta =
(current.horizontal && useX ? event.deltaX : event.deltaY) *
(event.deltaMode === 1
? 16
: event.deltaMode === 2
? current.horizontal
? element.clientWidth
: element.clientHeight
: 1);
if (!delta) return;
const enabled = current.options
.map((option, index) => ({ option, index }))
.filter(({ option }) => !option.disabled);
const first = enabled[0]?.index ?? 0;
const last = enabled[enabled.length - 1]?.index ?? first;
const from = wheelTarget.current ?? position.get();
// At an end, release wheel events back to the page's own scroll.
if ((from <= first && delta < 0) || (from >= last && delta > 0)) return;
event.preventDefault();
if (wheelTimer.current) clearTimeout(wheelTimer.current);
if (current.reducedMotion) {
const at = enabled.findIndex(
({ option }) => option.value === request.current,
);
const next =
enabled[clamp(at + Math.sign(delta), 0, enabled.length - 1)];
if (next) select(next.index, true);
} else {
if (wheelTarget.current === null) {
animation.current?.stop();
animation.current = null;
}
instant.current = false;
tracking.current = true;
activity.set(1);
const next = clamp(from + delta / current.spacing, first, last);
// Wheel deltas already contain trackpad momentum. Follow them directly;
// useTransform coalesces visual writes to one per frame. A spring here
// lags behind frequent input and can feel stuck until scrolling stops.
wheelTarget.current = next;
position.set(next);
wheelTimer.current = setTimeout(() => {
wheelTimer.current = null;
wheelTarget.current = null;
settle(nearestEnabled(next));
}, WHEEL_SETTLE_MS);
}
};
element.addEventListener("wheel", wheel, { passive: false });
return () => {
element.removeEventListener("wheel", wheel);
stop();
};
}, [activity, nearestEnabled, position, select, settle, stop]);
const keyboard = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
const enabled = options
.map((option, index) => ({ option, index }))
.filter(({ option }) => !option.disabled);
if (!enabled.length) return;
const at = Math.max(
0,
enabled.findIndex(({ option }) => option.value === request.current),
);
let next: number;
switch (event.key) {
case "ArrowDown":
case "ArrowRight":
next = at + (event.shiftKey ? 5 : 1);
break;
case "ArrowUp":
case "ArrowLeft":
next = at - (event.shiftKey ? 5 : 1);
break;
case "Home":
next = 0;
break;
case "End":
next = enabled.length - 1;
break;
case "PageDown":
next = at + 5;
break;
case "PageUp":
next = at - 5;
break;
default:
return;
}
event.preventDefault();
const option = enabled[clamp(next, 0, enabled.length - 1)];
if (option) select(option.index, true);
};
const release = (event: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
const current = drag.current;
if (!current || current.id !== event.pointerId) return;
drag.current = null;
if (root.current?.hasPointerCapture(event.pointerId))
root.current.releasePointerCapture(event.pointerId);
if (!current.moved) return;
suppressClick.current = true;
if (cancelled) {
tracking.current = false;
activity.set(0);
settle(latest.current.selectedIndex);
} else {
const velocity =
performance.now() - current.time > 100 ? 0 : current.velocity;
settle(
nearestEnabled(
position.get() + (reducedMotion ? 0 : velocity * COAST_SECONDS),
),
);
}
};
return (
<div
{...props}
ref={root}
role="radiogroup"
aria-label={label}
aria-describedby={[describedBy, instructionsId].filter(Boolean).join(" ")}
aria-disabled={disabled || undefined}
aria-orientation={horizontal ? "horizontal" : "vertical"}
data-slot="arc-picker"
data-side={side}
className={cn(
"relative w-full min-w-0 cursor-grab select-none overflow-hidden active:cursor-grabbing",
disabled && "cursor-default opacity-50",
className,
)}
style={{
touchAction: horizontal ? "pan-y pinch-zoom" : "pan-x pinch-zoom",
height: horizontal ? horizontalHeight : count * rowHeight,
maskImage: `linear-gradient(to ${horizontal ? "right" : "bottom"}, transparent, black 12%, black 88%, transparent)`,
...style,
}}
onKeyDown={keyboard}
onFocusCapture={(event) => {
focusWithin.current = true;
onFocusCapture?.(event);
}}
onBlurCapture={(event) => {
if (event.relatedTarget && !root.current?.contains(event.relatedTarget))
focusWithin.current = false;
onBlurCapture?.(event);
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (
event.defaultPrevented ||
disabled ||
selectedValue === undefined ||
event.button !== 0 ||
!event.isPrimary
)
return;
stop();
suppressClick.current = false;
const coordinate = horizontal ? event.clientX : event.clientY;
drag.current = {
id: event.pointerId,
coordinate,
position: position.get(),
lastCoordinate: coordinate,
time: performance.now(),
velocity: 0,
moved: false,
};
}}
onPointerMove={(event) => {
onPointerMove?.(event);
const current = drag.current;
if (
event.defaultPrevented ||
!current ||
current.id !== event.pointerId
)
return;
const coordinate = horizontal ? event.clientX : event.clientY;
const delta = coordinate - current.coordinate;
if (!current.moved && Math.abs(delta) < 6) return;
current.moved = true;
tracking.current = true;
instant.current = false;
activity.set(reducedMotion ? 0 : 1);
root.current?.setPointerCapture(event.pointerId);
const now = performance.now();
const elapsed = Math.max(8, now - current.time);
current.velocity =
current.velocity * 0.35 +
((current.lastCoordinate - coordinate) / spacing / elapsed) *
1000 *
0.65;
current.lastCoordinate = coordinate;
current.time = now;
const next = clamp(
current.position - delta / spacing,
0,
Math.max(0, options.length - 1),
);
position.set(reducedMotion ? Math.round(next) : next);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
release(event, false);
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
release(event, true);
}}
onLostPointerCapture={(event) => {
onLostPointerCapture?.(event);
release(event, true);
}}
onClickCapture={(event) => {
onClickCapture?.(event);
if (suppressClick.current && event.detail > 0) {
event.preventDefault();
event.stopPropagation();
suppressClick.current = false;
}
}}
>
<span id={instructionsId} className="sr-only">
Drag {horizontal ? "horizontally" : "vertically"} or scroll to choose.
Use arrow keys to move, Home and End to jump.
</span>
{name && (
<input
type="hidden"
name={name}
value={selectedValue ?? ""}
disabled={disabled}
/>
)}
{options.map((option, index) => (
<ArcOption
key={option.value}
option={option}
index={index}
position={position}
verticalAnchor={verticalAnchor}
radius={curveRadius}
itemHeight={spacing}
visibleCount={shownCount}
side={side}
selected={option.value === selectedValue}
disabled={disabled}
setRef={(element) => {
if (element) buttons.current.set(option.value, element);
else buttons.current.delete(option.value);
}}
onSelect={() => select(index)}
/>
))}
<ArcSelectionFrame
width={frameWidth}
expansion={expansion}
side={side}
verticalAnchor={verticalAnchor}
visible={selectedValue !== undefined}
/>
{!options.length && (
<span className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
No options
</span>
)}
</div>
);
}
export type {
ButtonLinkProps,
ButtonProps,
ButtonSize,
ButtonVariant,
} from "./base";
export { Button, ButtonLink } from "./base";
export type { MagneticButtonProps } from "./magnetic";
export { MagneticButton } from "./magnetic";
export type { MetallicButtonProps } from "./metallic";
export { MetallicButton } from "./metallic";
export type { ButtonState, StatefulButtonProps } from "./stateful";
export { StatefulButton } from "./stateful";
"use client";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
forwardRef,
type PointerEvent,
type ReactNode,
useCallback,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
/** Spawn a Material-style ripple from the press point. Off by default. */
ripple?: boolean;
children?: ReactNode;
}
export interface ButtonLinkProps extends Omit<
HTMLMotionProps<"a">,
"children"
> {
variant?: ButtonVariant;
size?: ButtonSize;
pressScale?: number;
children?: ReactNode;
}
type Ripple = { id: number; x: number; y: number; size: number };
const VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border border-border bg-card text-foreground hover:border-border",
ghost: "text-muted-foreground hover:text-foreground hover:bg-muted/60",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted/60",
};
const SIZE_CLASS: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs gap-1.5 rounded-full",
md: "h-10 px-5 text-sm gap-2 rounded-full",
lg: "h-12 px-6 text-base gap-2 rounded-full",
icon: "h-8 w-8 rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
pressScale = 0.93,
ripple = false,
className,
children,
onPointerDown,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [ripples, setRipples] = useState<Ripple[]>([]);
const nextId = useRef(0);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (ripple && !reduce) {
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const id = nextId.current++;
setRipples((prev) => [
...prev,
{
id,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
size,
},
]);
}
onPointerDown?.(event);
},
[ripple, reduce, onPointerDown],
);
return (
<motion.button
ref={ref}
type="button"
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
onPointerDown={handlePointerDown}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
ripple && "relative overflow-hidden",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{ripple && !reduce ? (
<span className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
x: "-50%",
y: "-50%",
}}
initial={{ scale: 0.05, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.6, ease: EASE_OUT }}
onAnimationComplete={() =>
setRipples((prev) => prev.filter((x) => x.id !== r.id))
}
/>
))}
</AnimatePresence>
</span>
) : null}
{children}
</motion.button>
);
},
);
export const ButtonLink = forwardRef<HTMLAnchorElement, ButtonLinkProps>(
function ButtonLink(
{
variant = "primary",
size = "md",
pressScale = 0.93,
className,
children,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
return (
<motion.a
ref={ref}
whileTap={reduce ? undefined : { scale: pressScale }}
whileHover={reduce || !canHover ? undefined : { scale: 1.02 }}
transition={SPRING_PRESS}
className={cn(
"inline-flex items-center justify-center font-medium select-none",
"transition-colors",
VARIANT_CLASS[variant],
SIZE_CLASS[size],
className,
)}
{...rest}
>
{children}
</motion.a>
);
},
);
"use client";
import { forwardRef } from "react";
import { Magnetic } from "../magnetic";
import { Button, type ButtonProps } from "./base";
export interface MagneticButtonProps extends ButtonProps {
/** Magnetic pull strength. Default 0.25. */
strength?: number;
/** Class applied to the magnetic wrapper. */
magneticClassName?: string;
}
export const MagneticButton = forwardRef<HTMLButtonElement, MagneticButtonProps>(function MagneticButton(
{ strength = 0.25, magneticClassName, children, ...rest },
ref,
) {
return (
<Magnetic strength={strength} className={magneticClassName}>
<Button ref={ref} {...rest}>
{children}
</Button>
</Magnetic>
);
});
"use client";
import { motion, useReducedMotion } from "motion/react";
import { forwardRef, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { Button, type ButtonProps } from "./base";
export interface MetallicButtonProps extends Omit<
ButtonProps,
"ripple" | "variant"
> {
/** Stops the traveling reflection while preserving the chrome rim. */
paused?: boolean;
}
// The rim and highlight drift separately so the material stays quiet and reflective.
const SILVER_DRIFT = {
duration: 8,
ease: EASE_IN_OUT,
repeat: Infinity,
};
const CHROME_SHIMMER = {
duration: 2.4,
ease: EASE_IN_OUT,
};
export const MetallicButton = forwardRef<
HTMLButtonElement,
MetallicButtonProps
>(function MetallicButton(
{
size = "md",
paused = false,
className,
children,
onHoverStart,
onHoverEnd,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const still = paused || Boolean(reduce);
const [hovered, setHovered] = useState(false);
return (
<Button
ref={ref}
variant="ghost"
size={size}
onHoverStart={(event, info) => {
setHovered(true);
onHoverStart?.(event, info);
}}
onHoverEnd={(event, info) => {
setHovered(false);
onHoverEnd?.(event, info);
}}
className={cn(
"group relative isolate overflow-hidden border-0 bg-transparent text-foreground",
"hover:bg-transparent hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"shadow-[0_8px_22px_rgba(0,0,0,0.16)]",
size === "icon" && "rounded-full",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-18%] z-0 w-[136%] rounded-[inherit] bg-[linear-gradient(105deg,#111_0%,#737373_14%,#fafafa_26%,#525252_38%,#0a0a0a_50%,#a3a3a3_64%,#fff_75%,#404040_87%,#111_100%)]"
animate={still ? undefined : { x: ["0%", "13%", "0%"] }}
transition={still ? undefined : SILVER_DRIFT}
/>
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[-58%] z-[1] w-[52%] -skew-x-12 bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.5)_48%,transparent)] opacity-50 blur-[3px] mix-blend-screen"
animate={still ? undefined : { x: hovered ? "310%" : "0%" }}
transition={still ? undefined : CHROME_SHIMMER}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[2] rounded-[inherit] bg-background transition-colors group-hover:bg-muted/40"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[2px] z-[3] rounded-[inherit] shadow-[inset_0_1px_0_rgba(255,255,255,0.28),inset_0_-1px_0_rgba(0,0,0,0.16)]"
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">
{children}
</span>
</Button>
);
});
"use client";
import { Check, Loader2, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import {
forwardRef,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_SWAP } from "@/lib/ease";
import { Button, type ButtonProps } from "./base";
export type ButtonState = "idle" | "loading" | "success" | "error";
export interface StatefulButtonProps extends Omit<ButtonProps, "children"> {
state?: ButtonState;
children: ReactNode;
loadingText?: ReactNode;
successText?: ReactNode;
errorText?: ReactNode;
icon?: ReactNode;
}
const CASCADE_STAGGER = 0.025;
const ROLL_BLUR = "blur(6px)";
const CASCADE_LETTER_VARIANTS: Variants = {
initial: { opacity: 0, y: "105%", filter: ROLL_BLUR },
animate: (delay: number = 0) => ({
opacity: 1,
y: "0%",
filter: "blur(0px)",
transition: { ...SPRING_SWAP, delay },
}),
exit: (delay: number = 0) => ({
opacity: 0,
y: "-105%",
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 },
}),
};
const ICON_VARIANTS: Variants = {
// Width collapses too, so the icon adds/removes its own space smoothly
// instead of popping the row width in a single frame.
initial: { opacity: 0, width: 0, scale: 0.7, filter: ROLL_BLUR },
animate: {
opacity: 1,
width: "1.5rem",
scale: 1,
filter: "blur(0px)",
transition: SPRING_SWAP,
},
exit: {
opacity: 0,
width: 0,
scale: 0.7,
filter: ROLL_BLUR,
transition: { duration: 0.16, ease: EASE_OUT },
},
};
function IconSlot({ keyId, children }: { keyId: string; children: ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.span
key={keyId}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "initial"}
animate={reduce ? { opacity: 1 } : "animate"}
exit={reduce ? { opacity: 0 } : "exit"}
transition={reduce ? { duration: 0.15 } : undefined}
className="inline-grid shrink-0 place-items-center overflow-hidden"
>
{children}
</motion.span>
);
}
function TextSlot({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const reduce = useReducedMotion();
const measureRef = useRef<HTMLSpanElement>(null);
const [width, setWidth] = useState<number>();
const label = typeof children === "string" ? children : null;
const cascade = label !== null && !reduce;
// Measure strings with the same per-letter layout as the cascade. Measuring
// the whole string preserves kerning, which can make it narrower than the
// inline-block letters and clip the final glyph during the width animation.
useLayoutEffect(() => {
const nextWidth = measureRef.current?.offsetWidth;
if (!nextWidth) return;
setWidth((current) => (current === nextWidth ? current : nextWidth));
});
return (
<motion.span
initial={false}
animate={{ width }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="relative inline-block overflow-hidden whitespace-nowrap align-bottom"
>
<span
ref={measureRef}
aria-hidden
className="invisible inline-block whitespace-nowrap"
>
{cascade
? label.split("").map((char, index) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
className="inline-block whitespace-pre"
>
{char}
</span>
))
: children}
</span>
{cascade ? (
<>
<span className="sr-only">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={`cascade-${value}`}
aria-hidden
initial="initial"
animate="animate"
exit="exit"
className="absolute left-0 top-0 inline-block whitespace-pre"
>
{label.split("").map((char, index) => (
<motion.span
// biome-ignore lint/suspicious/noArrayIndexKey: position is the slot identity.
key={index}
custom={index * CASCADE_STAGGER}
variants={CASCADE_LETTER_VARIANTS}
className="inline-block whitespace-pre will-change-[opacity,filter,transform]"
>
{char}
</motion.span>
))}
</motion.span>
</AnimatePresence>
</>
) : (
<AnimatePresence initial={false}>
<motion.span
key={`text-${value}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: ROLL_BLUR }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -14, filter: ROLL_BLUR }}
transition={reduce ? { duration: 0.15 } : SPRING_SWAP}
className="absolute left-0 top-0 inline-block will-change-[opacity,filter,transform]"
>
{children}
</motion.span>
</AnimatePresence>
)}
</motion.span>
);
}
export const StatefulButton = forwardRef<HTMLButtonElement, StatefulButtonProps>(function StatefulButton(
{
state = "idle",
children,
loadingText = "Loading",
successText = "Done",
errorText = "Try again",
icon,
disabled,
...rest
},
ref,
) {
const isBusy = state === "loading";
const stateText =
state === "loading"
? loadingText
: state === "success"
? successText
: state === "error"
? errorText
: children;
const textKey =
typeof stateText === "string" ? `${state}-${stateText}` : state;
return (
<Button ref={ref} disabled={disabled || isBusy} aria-busy={isBusy} whileHover={undefined} {...rest}>
<span
aria-live="polite"
className="relative inline-flex items-center justify-center overflow-hidden"
>
<AnimatePresence initial={false}>
{state === "loading" ? (
<IconSlot keyId="loading-icon">
<Loader2 className="h-4 w-4 animate-spin" />
</IconSlot>
) : null}
{state === "success" ? (
<IconSlot keyId="success-icon">
<Check className="h-4 w-4" />
</IconSlot>
) : null}
{state === "error" ? (
<IconSlot keyId="error-icon">
<X className="h-4 w-4" />
</IconSlot>
) : null}
</AnimatePresence>
<TextSlot value={textKey}>{stateText}</TextSlot>
<AnimatePresence initial={false}>
{state === "idle" && icon ? (
<IconSlot keyId="idle-icon">{icon}</IconSlot>
) : null}
</AnimatePresence>
</span>
</Button>
);
});
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
className?string—Related components
Range Slider
Slider with tick dots and a vertical-bar thumb that bounces as it lands on each step. Drag or keyboard, reduced-motion safe.
Wheel Picker
iOS-style picker wheel: a 3D drum on native momentum scroll that snaps to the nearest notch, with wheel, drag and keyboard control. Composes side by side for date and time pickers, reduced-motion safe.
Input
Text input with label, left/right icons, optional stable error row, error shake and success check draw.
Updated