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-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>) => void)—ref?any—AdaptiveStepperIncrement
onClick?((event: MouseEvent<HTMLButtonElement>) => void)—ref?any—Updated