Adaptive Stepper
Composable numeric stepper whose fixed footprint adapts at its minimum and maximum while the value rolls between steps.
Preview
TSXcomponents/previews/motion/adaptive-stepper.preview.tsx
"use client";
import {
AdaptiveStepper,
AdaptiveStepperDecrement,
AdaptiveStepperIncrement,
AdaptiveStepperValue,
} from "@/components/motion/adaptive-stepper";
export function AdaptiveStepperPreview() {
return (
<div className="flex min-h-[320px] w-full items-center justify-center px-4">
<AdaptiveStepper defaultValue={2} min={0} max={3} aria-label="Guests">
<AdaptiveStepperDecrement />
<AdaptiveStepperValue />
<AdaptiveStepperIncrement />
</AdaptiveStepper>
</div>
);
}
TSXcomponents/motion/adaptive-stepper.tsx
"use client";
// beui.dev/components/motion/adaptive-stepper
import { Minus, Plus } from "lucide-react";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
createContext,
type MouseEvent,
type ReactNode,
type Ref,
useCallback,
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
EASE_OUT,
SPRING_PRESS,
} from "@/lib/ease";
import {
Liquid,
LiquidItem,
type LiquidTransition,
} from "@/components/motion/liquid";
import { cn } from "@/lib/utils";
// The deliberately elastic separation curve from the liquid email reference.
const STEPPER_LIQUID_TRANSITION = {
duration: 600,
ease: [0.22, 1.3, 0.71, 1],
} as const satisfies LiquidTransition;
type StepDirection = -1 | 0 | 1;
type AdaptiveStepperContextValue = {
value: number;
valueText: string;
direction: StepDirection;
atMin: boolean;
atMax: boolean;
disabled: boolean;
reduce: boolean;
decrement: (restoreFocus: boolean) => void;
increment: (restoreFocus: boolean) => void;
decrementRef: React.MutableRefObject<HTMLButtonElement | null>;
incrementRef: React.MutableRefObject<HTMLButtonElement | null>;
};
const AdaptiveStepperContext = createContext<AdaptiveStepperContextValue | null>(
null,
);
function useAdaptiveStepperContext(component: string) {
const context = useContext(AdaptiveStepperContext);
if (!context) {
throw new Error(`${component} must be used within <AdaptiveStepper>`);
}
return context;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function cleanNumber(value: number) {
return Number(value.toFixed(10));
}
function nextStep(
value: number,
direction: -1 | 1,
min: number,
max: number,
step: number,
) {
if (direction === 1) {
const nextIndex = Math.floor((value - min) / step + 1e-10) + 1;
return cleanNumber(Math.min(max, min + nextIndex * step));
}
const previousIndex = Math.ceil((value - min) / step - 1e-10) - 1;
return cleanNumber(Math.max(min, min + previousIndex * step));
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<T | null>).current = node;
}
}
};
}
export interface AdaptiveStepperProps {
children: ReactNode;
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
name?: string;
formatValueText?: (value: number) => string;
className?: string;
"aria-label"?: string;
}
export function AdaptiveStepper({
children,
value: controlledValue,
defaultValue = 0,
onValueChange,
min = 0,
max = 10,
step = 1,
disabled = false,
name,
formatValueText,
className,
"aria-label": ariaLabel = "Quantity",
}: AdaptiveStepperProps) {
const reduce = useReducedMotion() ?? false;
const labelId = useId();
const decrementRef = useRef<HTMLButtonElement>(null);
const incrementRef = useRef<HTMLButtonElement>(null);
const lower = Number.isFinite(min) ? min : 0;
const suppliedMax = Number.isFinite(max) ? max : lower;
const upper = suppliedMax > lower ? suppliedMax : lower;
const stride = Number.isFinite(step) && step > 0 ? step : 1;
const [internalValue, setInternalValue] = useState(() =>
clamp(Number.isFinite(defaultValue) ? defaultValue : lower, lower, upper),
);
const controlled = controlledValue !== undefined;
const suppliedValue = controlled ? controlledValue : internalValue;
const currentValue = clamp(
Number.isFinite(suppliedValue) ? suppliedValue : lower,
lower,
upper,
);
const previousValueRef = useRef(currentValue);
const currentValueRef = useRef(currentValue);
const direction: StepDirection =
currentValue === previousValueRef.current
? 0
: currentValue > previousValueRef.current
? 1
: -1;
useLayoutEffect(() => {
previousValueRef.current = currentValue;
currentValueRef.current = currentValue;
}, [currentValue]);
const commit = useCallback(
(nextValue: number, restoreFocus: boolean) => {
const next = clamp(cleanNumber(nextValue), lower, upper);
if (next === currentValue) return;
if (!controlled) setInternalValue(next);
onValueChange?.(next);
if (!restoreFocus) return;
requestAnimationFrame(() => {
if (next === upper && currentValueRef.current === upper) {
decrementRef.current?.focus();
} else if (next === lower && currentValueRef.current === lower) {
incrementRef.current?.focus();
}
});
},
[controlled, currentValue, lower, onValueChange, upper],
);
const decrement = useCallback(
(restoreFocus: boolean) => {
if (disabled || currentValue <= lower) return;
commit(nextStep(currentValue, -1, lower, upper, stride), restoreFocus);
},
[commit, currentValue, disabled, lower, stride, upper],
);
const increment = useCallback(
(restoreFocus: boolean) => {
if (disabled || currentValue >= upper) return;
commit(nextStep(currentValue, 1, lower, upper, stride), restoreFocus);
},
[commit, currentValue, disabled, lower, stride, upper],
);
const valueText = formatValueText?.(currentValue) ?? String(currentValue);
const context = useMemo<AdaptiveStepperContextValue>(
() => ({
value: currentValue,
valueText,
direction,
atMin: currentValue <= lower,
atMax: currentValue >= upper,
disabled,
reduce,
decrement,
increment,
decrementRef,
incrementRef,
}),
[
currentValue,
decrement,
direction,
disabled,
increment,
lower,
reduce,
upper,
valueText,
],
);
return (
<AdaptiveStepperContext.Provider value={context}>
<fieldset
disabled={disabled}
className={cn(
"relative isolate m-0 inline-block h-12 w-[13.5rem] border-0 p-0",
className,
)}
>
<legend id={labelId} className="sr-only">
{ariaLabel}. Current value: {valueText}
</legend>
<Liquid
blur={8}
contrast={22}
fill="var(--background)"
className="size-full"
>
{children}
</Liquid>
{name ? <input type="hidden" name={name} value={currentValue} /> : null}
</fieldset>
</AdaptiveStepperContext.Provider>
);
}
export interface AdaptiveStepperActionProps
extends Omit<HTMLMotionProps<"button">, "children" | "onClick"> {
children?: ReactNode;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
ref?: Ref<HTMLButtonElement>;
}
function StepperAction({
direction,
children,
className,
onClick,
ref,
style,
tabIndex,
"aria-label": ariaLabel,
...props
}: AdaptiveStepperActionProps & { direction: -1 | 1 }) {
const context = useAdaptiveStepperContext("AdaptiveStepper action");
const hidden = direction === -1 ? context.atMin : context.atMax;
const actionRef =
direction === -1 ? context.decrementRef : context.incrementRef;
const label =
ariaLabel ?? (direction === -1 ? "Decrease value" : "Increase value");
const action = direction === -1 ? context.decrement : context.increment;
const x =
direction === -1
? hidden
? 32
: 0
: hidden
? 136
: 168;
return (
<LiquidItem
x={x}
y={0}
width={48}
height={48}
radius={24}
transition={STEPPER_LIQUID_TRANSITION}
>
<motion.button
{...props}
ref={mergeRefs(ref, actionRef)}
type="button"
aria-label={label}
aria-hidden={hidden || undefined}
tabIndex={hidden ? -1 : tabIndex}
disabled={context.disabled || hidden}
whileTap={
context.reduce || context.disabled || hidden
? undefined
: { scale: 0.94 }
}
transition={context.reduce ? { duration: 0 } : SPRING_PRESS}
style={style}
className={cn(
"grid size-full place-items-center rounded-full border border-transparent bg-transparent bg-clip-padding text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none",
hidden && "hover:bg-transparent",
context.disabled && "opacity-50",
className,
)}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) action(event.detail === 0);
}}
>
<motion.span
aria-hidden="true"
initial={{
opacity: hidden ? 0 : 1,
filter: hidden ? "blur(2px)" : "blur(0px)",
}}
animate={{
opacity: hidden ? 0 : 1,
filter: hidden ? "blur(2px)" : "blur(0px)",
}}
transition={{ duration: context.reduce ? 0 : 0.15, ease: EASE_OUT }}
>
{children ??
(direction === -1 ? (
<Minus className="size-5" strokeWidth={2.5} />
) : (
<Plus className="size-5" strokeWidth={2.5} />
))}
</motion.span>
</motion.button>
</LiquidItem>
);
}
export function AdaptiveStepperDecrement(props: AdaptiveStepperActionProps) {
return <StepperAction {...props} direction={-1} />;
}
export interface AdaptiveStepperValueProps
extends Omit<HTMLMotionProps<"output">, "children"> {
children?: ReactNode | ((value: number) => ReactNode);
}
export function AdaptiveStepperValue({
children,
className,
style,
...props
}: AdaptiveStepperValueProps) {
const context = useAdaptiveStepperContext("AdaptiveStepperValue");
const geometry =
context.atMin && context.atMax
? { x: 0, width: 216 }
: context.atMin
? { x: 0, width: 152 }
: context.atMax
? { x: 64, width: 152 }
: { x: 64, width: 88 };
const displayValue =
typeof children === "function" ? children(context.value) : children;
const renderedValue = displayValue ?? context.value;
const canRoll =
typeof renderedValue === "number" || typeof renderedValue === "string";
const distance = context.reduce || !canRoll ? 0 : context.direction * 32;
const enterFrom = `translateY(${distance}%)`;
const exitTo = `translateY(${-distance}%)`;
return (
<LiquidItem
x={geometry.x}
y={0}
width={geometry.width}
height={48}
radius={24}
transition={STEPPER_LIQUID_TRANSITION}
>
<motion.output
{...props}
aria-live="polite"
aria-atomic="true"
style={style}
className={cn(
"flex size-full min-w-0 items-center justify-center overflow-hidden rounded-full border border-transparent bg-transparent bg-clip-padding px-4 text-lg font-semibold tabular-nums text-foreground",
className,
)}
>
<span className="sr-only">{context.valueText}</span>
<span
aria-hidden="true"
className="relative grid min-h-[1.1em] min-w-[1ch] place-items-center overflow-hidden leading-none"
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={context.value}
initial={{
opacity: context.reduce ? 1 : 0.35,
filter: context.reduce ? "blur(0px)" : "blur(2px)",
transform: enterFrom,
}}
animate={{
opacity: 1,
filter: "blur(0px)",
transform: "translateY(0%)",
}}
exit={{
opacity: context.reduce ? 1 : 0,
filter: context.reduce ? "blur(0px)" : "blur(2px)",
transform: exitTo,
transition: {
duration: context.reduce ? 0 : 0.12,
ease: EASE_OUT,
},
}}
transition={{
duration: context.reduce ? 0 : 0.18,
ease: EASE_OUT,
}}
className="col-start-1 row-start-1 will-change-[transform,filter,opacity]"
>
{renderedValue}
</motion.span>
</AnimatePresence>
</span>
</motion.output>
</LiquidItem>
);
}
export function AdaptiveStepperIncrement(props: AdaptiveStepperActionProps) {
return <StepperAction {...props} direction={1} />;
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/adaptive-stepperadaptive-stepperadaptive-stepper
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** 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;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/adaptive-stepper.tsx
"use client";
// beui.dev/components/motion/adaptive-stepper
import { Minus, Plus } from "lucide-react";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
createContext,
type MouseEvent,
type ReactNode,
type Ref,
useCallback,
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
EASE_OUT,
SPRING_PRESS,
} from "@/lib/ease";
import {
Liquid,
LiquidItem,
type LiquidTransition,
} from "@/components/motion/liquid";
import { cn } from "@/lib/utils";
// The deliberately elastic separation curve from the liquid email reference.
const STEPPER_LIQUID_TRANSITION = {
duration: 600,
ease: [0.22, 1.3, 0.71, 1],
} as const satisfies LiquidTransition;
type StepDirection = -1 | 0 | 1;
type AdaptiveStepperContextValue = {
value: number;
valueText: string;
direction: StepDirection;
atMin: boolean;
atMax: boolean;
disabled: boolean;
reduce: boolean;
decrement: (restoreFocus: boolean) => void;
increment: (restoreFocus: boolean) => void;
decrementRef: React.MutableRefObject<HTMLButtonElement | null>;
incrementRef: React.MutableRefObject<HTMLButtonElement | null>;
};
const AdaptiveStepperContext = createContext<AdaptiveStepperContextValue | null>(
null,
);
function useAdaptiveStepperContext(component: string) {
const context = useContext(AdaptiveStepperContext);
if (!context) {
throw new Error(`${component} must be used within <AdaptiveStepper>`);
}
return context;
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function cleanNumber(value: number) {
return Number(value.toFixed(10));
}
function nextStep(
value: number,
direction: -1 | 1,
min: number,
max: number,
step: number,
) {
if (direction === 1) {
const nextIndex = Math.floor((value - min) / step + 1e-10) + 1;
return cleanNumber(Math.min(max, min + nextIndex * step));
}
const previousIndex = Math.ceil((value - min) / step - 1e-10) - 1;
return cleanNumber(Math.max(min, min + previousIndex * step));
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<T | null>).current = node;
}
}
};
}
export interface AdaptiveStepperProps {
children: ReactNode;
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
name?: string;
formatValueText?: (value: number) => string;
className?: string;
"aria-label"?: string;
}
export function AdaptiveStepper({
children,
value: controlledValue,
defaultValue = 0,
onValueChange,
min = 0,
max = 10,
step = 1,
disabled = false,
name,
formatValueText,
className,
"aria-label": ariaLabel = "Quantity",
}: AdaptiveStepperProps) {
const reduce = useReducedMotion() ?? false;
const labelId = useId();
const decrementRef = useRef<HTMLButtonElement>(null);
const incrementRef = useRef<HTMLButtonElement>(null);
const lower = Number.isFinite(min) ? min : 0;
const suppliedMax = Number.isFinite(max) ? max : lower;
const upper = suppliedMax > lower ? suppliedMax : lower;
const stride = Number.isFinite(step) && step > 0 ? step : 1;
const [internalValue, setInternalValue] = useState(() =>
clamp(Number.isFinite(defaultValue) ? defaultValue : lower, lower, upper),
);
const controlled = controlledValue !== undefined;
const suppliedValue = controlled ? controlledValue : internalValue;
const currentValue = clamp(
Number.isFinite(suppliedValue) ? suppliedValue : lower,
lower,
upper,
);
const previousValueRef = useRef(currentValue);
const currentValueRef = useRef(currentValue);
const direction: StepDirection =
currentValue === previousValueRef.current
? 0
: currentValue > previousValueRef.current
? 1
: -1;
useLayoutEffect(() => {
previousValueRef.current = currentValue;
currentValueRef.current = currentValue;
}, [currentValue]);
const commit = useCallback(
(nextValue: number, restoreFocus: boolean) => {
const next = clamp(cleanNumber(nextValue), lower, upper);
if (next === currentValue) return;
if (!controlled) setInternalValue(next);
onValueChange?.(next);
if (!restoreFocus) return;
requestAnimationFrame(() => {
if (next === upper && currentValueRef.current === upper) {
decrementRef.current?.focus();
} else if (next === lower && currentValueRef.current === lower) {
incrementRef.current?.focus();
}
});
},
[controlled, currentValue, lower, onValueChange, upper],
);
const decrement = useCallback(
(restoreFocus: boolean) => {
if (disabled || currentValue <= lower) return;
commit(nextStep(currentValue, -1, lower, upper, stride), restoreFocus);
},
[commit, currentValue, disabled, lower, stride, upper],
);
const increment = useCallback(
(restoreFocus: boolean) => {
if (disabled || currentValue >= upper) return;
commit(nextStep(currentValue, 1, lower, upper, stride), restoreFocus);
},
[commit, currentValue, disabled, lower, stride, upper],
);
const valueText = formatValueText?.(currentValue) ?? String(currentValue);
const context = useMemo<AdaptiveStepperContextValue>(
() => ({
value: currentValue,
valueText,
direction,
atMin: currentValue <= lower,
atMax: currentValue >= upper,
disabled,
reduce,
decrement,
increment,
decrementRef,
incrementRef,
}),
[
currentValue,
decrement,
direction,
disabled,
increment,
lower,
reduce,
upper,
valueText,
],
);
return (
<AdaptiveStepperContext.Provider value={context}>
<fieldset
disabled={disabled}
className={cn(
"relative isolate m-0 inline-block h-12 w-[13.5rem] border-0 p-0",
className,
)}
>
<legend id={labelId} className="sr-only">
{ariaLabel}. Current value: {valueText}
</legend>
<Liquid
blur={8}
contrast={22}
fill="var(--background)"
className="size-full"
>
{children}
</Liquid>
{name ? <input type="hidden" name={name} value={currentValue} /> : null}
</fieldset>
</AdaptiveStepperContext.Provider>
);
}
export interface AdaptiveStepperActionProps
extends Omit<HTMLMotionProps<"button">, "children" | "onClick"> {
children?: ReactNode;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
ref?: Ref<HTMLButtonElement>;
}
function StepperAction({
direction,
children,
className,
onClick,
ref,
style,
tabIndex,
"aria-label": ariaLabel,
...props
}: AdaptiveStepperActionProps & { direction: -1 | 1 }) {
const context = useAdaptiveStepperContext("AdaptiveStepper action");
const hidden = direction === -1 ? context.atMin : context.atMax;
const actionRef =
direction === -1 ? context.decrementRef : context.incrementRef;
const label =
ariaLabel ?? (direction === -1 ? "Decrease value" : "Increase value");
const action = direction === -1 ? context.decrement : context.increment;
const x =
direction === -1
? hidden
? 32
: 0
: hidden
? 136
: 168;
return (
<LiquidItem
x={x}
y={0}
width={48}
height={48}
radius={24}
transition={STEPPER_LIQUID_TRANSITION}
>
<motion.button
{...props}
ref={mergeRefs(ref, actionRef)}
type="button"
aria-label={label}
aria-hidden={hidden || undefined}
tabIndex={hidden ? -1 : tabIndex}
disabled={context.disabled || hidden}
whileTap={
context.reduce || context.disabled || hidden
? undefined
: { scale: 0.94 }
}
transition={context.reduce ? { duration: 0 } : SPRING_PRESS}
style={style}
className={cn(
"grid size-full place-items-center rounded-full border border-transparent bg-transparent bg-clip-padding text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none",
hidden && "hover:bg-transparent",
context.disabled && "opacity-50",
className,
)}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) action(event.detail === 0);
}}
>
<motion.span
aria-hidden="true"
initial={{
opacity: hidden ? 0 : 1,
filter: hidden ? "blur(2px)" : "blur(0px)",
}}
animate={{
opacity: hidden ? 0 : 1,
filter: hidden ? "blur(2px)" : "blur(0px)",
}}
transition={{ duration: context.reduce ? 0 : 0.15, ease: EASE_OUT }}
>
{children ??
(direction === -1 ? (
<Minus className="size-5" strokeWidth={2.5} />
) : (
<Plus className="size-5" strokeWidth={2.5} />
))}
</motion.span>
</motion.button>
</LiquidItem>
);
}
export function AdaptiveStepperDecrement(props: AdaptiveStepperActionProps) {
return <StepperAction {...props} direction={-1} />;
}
export interface AdaptiveStepperValueProps
extends Omit<HTMLMotionProps<"output">, "children"> {
children?: ReactNode | ((value: number) => ReactNode);
}
export function AdaptiveStepperValue({
children,
className,
style,
...props
}: AdaptiveStepperValueProps) {
const context = useAdaptiveStepperContext("AdaptiveStepperValue");
const geometry =
context.atMin && context.atMax
? { x: 0, width: 216 }
: context.atMin
? { x: 0, width: 152 }
: context.atMax
? { x: 64, width: 152 }
: { x: 64, width: 88 };
const displayValue =
typeof children === "function" ? children(context.value) : children;
const renderedValue = displayValue ?? context.value;
const canRoll =
typeof renderedValue === "number" || typeof renderedValue === "string";
const distance = context.reduce || !canRoll ? 0 : context.direction * 32;
const enterFrom = `translateY(${distance}%)`;
const exitTo = `translateY(${-distance}%)`;
return (
<LiquidItem
x={geometry.x}
y={0}
width={geometry.width}
height={48}
radius={24}
transition={STEPPER_LIQUID_TRANSITION}
>
<motion.output
{...props}
aria-live="polite"
aria-atomic="true"
style={style}
className={cn(
"flex size-full min-w-0 items-center justify-center overflow-hidden rounded-full border border-transparent bg-transparent bg-clip-padding px-4 text-lg font-semibold tabular-nums text-foreground",
className,
)}
>
<span className="sr-only">{context.valueText}</span>
<span
aria-hidden="true"
className="relative grid min-h-[1.1em] min-w-[1ch] place-items-center overflow-hidden leading-none"
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={context.value}
initial={{
opacity: context.reduce ? 1 : 0.35,
filter: context.reduce ? "blur(0px)" : "blur(2px)",
transform: enterFrom,
}}
animate={{
opacity: 1,
filter: "blur(0px)",
transform: "translateY(0%)",
}}
exit={{
opacity: context.reduce ? 1 : 0,
filter: context.reduce ? "blur(0px)" : "blur(2px)",
transform: exitTo,
transition: {
duration: context.reduce ? 0 : 0.12,
ease: EASE_OUT,
},
}}
transition={{
duration: context.reduce ? 0 : 0.18,
ease: EASE_OUT,
}}
className="col-start-1 row-start-1 will-change-[transform,filter,opacity]"
>
{renderedValue}
</motion.span>
</AnimatePresence>
</span>
</motion.output>
</LiquidItem>
);
}
export function AdaptiveStepperIncrement(props: AdaptiveStepperActionProps) {
return <StepperAction {...props} direction={1} />;
}
TSXcomponents/motion/liquid.tsx
"use client";
import { useReducedMotion } from "motion/react";
import {
createContext,
forwardRef,
type HTMLAttributes,
type ReactNode,
type Ref,
useCallback,
useContext,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type LiquidContextValue = {
getRoot: () => HTMLDivElement | null;
getPortal: () => SVGGElement | null;
};
const LiquidContext = createContext<LiquidContextValue | null>(null);
function useLiquidContext() {
const context = useContext(LiquidContext);
if (!context) throw new Error("LiquidItem must be used within <Liquid>");
return context;
}
export type LiquidEase = readonly [number, number, number, number];
export type LiquidTransition = {
duration?: number;
ease?: LiquidEase;
};
export interface LiquidProps extends HTMLAttributes<HTMLDivElement> {
blur?: number;
contrast?: number;
fill?: string;
edgeColor?: string;
edgeOpacity?: number;
edgeWidth?: number;
filterPadding?: number;
}
export const Liquid = forwardRef<HTMLDivElement, LiquidProps>(function Liquid(
{
blur = 6,
contrast = 18,
fill = "var(--background)",
edgeColor = "var(--foreground)",
edgeOpacity = 0.08,
edgeWidth = 1,
filterPadding = 24,
className,
style,
children,
...props
},
forwardedRef: Ref<HTMLDivElement>,
) {
const rootRef = useRef<HTMLDivElement>(null);
const portalRef = useRef<SVGGElement>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
const filterId = `liquid-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const intercept = Math.round((0.5 - contrast * (5 / 12)) * 100) / 100;
const padding = Math.ceil(blur * 3 + filterPadding);
const setRootRef = useCallback(
(node: HTMLDivElement | null) => {
rootRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const measure = () => {
const next = {
width: root.offsetWidth,
height: root.offsetHeight,
};
setSize((current) =>
current.width === next.width && current.height === next.height
? current
: next,
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, []);
const context = useMemo<LiquidContextValue>(
() => ({
getRoot: () => rootRef.current,
getPortal: () => portalRef.current,
}),
[],
);
return (
<div
{...props}
ref={setRootRef}
className={cn("relative isolate", className)}
style={style}
>
<svg
aria-hidden="true"
focusable="false"
className="pointer-events-none absolute inset-0 z-0 size-full overflow-visible"
>
<defs>
<filter
id={filterId}
x={-padding}
y={-padding}
width={size.width + padding * 2}
height={size.height + padding * 2}
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur
in="SourceGraphic"
stdDeviation={blur}
result="blur"
/>
<feColorMatrix
in="blur"
type="matrix"
values={`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${contrast} ${intercept}`}
result="goo"
/>
<feComposite
in="SourceGraphic"
in2="goo"
operator="atop"
result="shape"
/>
{edgeWidth > 0 ? (
<>
<feColorMatrix
in="shape"
type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 60 -29.5"
result="solid-shape"
/>
<feMorphology
in="solid-shape"
operator="erode"
radius={edgeWidth}
result="inset-shape"
/>
<feComposite
in="solid-shape"
in2="inset-shape"
operator="out"
result="edge-mask"
/>
<feFlood
floodColor={edgeColor}
floodOpacity={edgeOpacity}
result="edge-color"
/>
<feComposite
in="edge-color"
in2="edge-mask"
operator="in"
result="edge"
/>
<feMerge>
<feMergeNode in="shape" />
<feMergeNode in="edge" />
</feMerge>
</>
) : null}
</filter>
</defs>
<g ref={portalRef} fill={fill} filter={`url(#${filterId})`} />
</svg>
<LiquidContext.Provider value={context}>
{children}
</LiquidContext.Provider>
</div>
);
});
type LiquidBox = {
x: number;
y: number;
width: number;
height: number;
radius: number;
};
export interface LiquidItemProps
extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
children: ReactNode;
x: number;
y: number;
width: number;
height: number;
radius?: number;
transition?: LiquidTransition;
}
function mix(from: number, to: number, progress: number) {
return from + (to - from) * progress;
}
function cubicBezier([x1, y1, x2, y2]: LiquidEase) {
return (progress: number) => {
if (progress <= 0) return 0;
if (progress >= 1) return 1;
let lower = 0;
let upper = 1;
for (let index = 0; index < 20; index++) {
const time = (lower + upper) / 2;
const inverse = 1 - time;
const x =
3 * inverse * inverse * time * x1 +
3 * inverse * time * time * x2 +
time ** 3;
if (x < progress) lower = time;
else upper = time;
}
const time = (lower + upper) / 2;
const inverse = 1 - time;
return (
3 * inverse * inverse * time * y1 +
3 * inverse * time * time * y2 +
time ** 3
);
};
}
export function LiquidItem({
children,
x,
y,
width,
height,
radius = Math.min(width, height) / 2,
transition,
className,
style,
...props
}: LiquidItemProps) {
const context = useLiquidContext();
const reduce = useReducedMotion() ?? false;
const wrapperRef = useRef<HTMLDivElement>(null);
const [blob, setBlob] = useState<SVGRectElement | null>(null);
const currentRef = useRef<LiquidBox | null>(null);
const duration = reduce ? 0 : (transition?.duration ?? 280);
const ease = transition?.ease ?? EASE_OUT;
const [x1, y1, x2, y2] = ease;
useLayoutEffect(() => {
const portal = context.getPortal();
if (!portal) return;
const blob = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect",
);
blob.setAttribute("x", "0");
blob.setAttribute("y", "0");
blob.style.transformBox = "fill-box";
blob.style.transformOrigin = "center";
blob.style.willChange = "transform";
portal.append(blob);
setBlob(blob);
return () => {
blob.remove();
};
}, [context]);
useLayoutEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || !blob || !context.getRoot()) return;
const target = { x, y, width, height, radius };
const write = (box: LiquidBox) => {
// Keep the interactive surface and its filtered silhouette on the same
// frame so neither can visually outrun the other during a morph.
const transform = `translate(${box.x}px, ${box.y}px)`;
wrapper.style.transform = transform;
wrapper.style.width = `${box.width}px`;
wrapper.style.height = `${box.height}px`;
blob.style.transform = transform;
blob.setAttribute("width", String(box.width));
blob.setAttribute("height", String(box.height));
blob.setAttribute("rx", String(box.radius));
};
const from = currentRef.current;
if (!from || duration === 0) {
currentRef.current = target;
write(target);
return;
}
const easing = cubicBezier([x1, y1, x2, y2]);
const startedAt = performance.now();
let frame = 0;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / duration);
const eased = easing(progress);
const current = {
x: mix(from.x, target.x, eased),
y: mix(from.y, target.y, eased),
width: mix(from.width, target.width, eased),
height: mix(from.height, target.height, eased),
radius: mix(from.radius, target.radius, eased),
};
currentRef.current = current;
write(current);
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [blob, context, duration, height, radius, width, x, x1, x2, y, y1, y2]);
return (
<div
{...props}
ref={wrapperRef}
className={cn("absolute left-0 top-0 z-10", className)}
style={{ ...style, willChange: "transform, width, height" }}
>
{children}
</div>
);
}
API Reference
AdaptiveStepper
value?number—defaultValue?number0onValueChange?((value: number) => void)—min?number0max?number10step?number1disabled?booleanfalsename?string—formatValueText?((value: number) => string)—className?string—aria-label?stringQuantityAdaptiveStepperDecrement
onClick?((event: MouseEvent<HTMLButtonElement, MouseEvent>) => void)—ref?Ref<HTMLButtonElement>—className?string—AdaptiveStepperValue
className?string—AdaptiveStepperIncrement
onClick?((event: MouseEvent<HTMLButtonElement, MouseEvent>) => void)—ref?Ref<HTMLButtonElement>—className?string—Related components
Center Morph Modal
A composable modal whose full-size surface unfolds from its exact center toward every edge, then folds back the same way with an inset close control.
Select
Composable select primitives whose panel bouncily unfolds out of the trigger and separates, plus a Morph variant where the trigger grows into the panel via shared layout.
Bounce Sidebar
A vertical sidebar whose active dot jumps between destinations on a curved, spring-loaded path.
Updated