Animated CTA Buttons
NewExpressive call-to-action buttons with expanding, hold, and slide interactions.
Updated
Expanding Arrow Button
expanding-arrow-button.tsxAn accent tile that expands into a dotted-arrow trail on hover or focus.
TSXcomponents/previews/motion/expanding-arrow-button.preview.tsx
"use client";
import { ExpandingArrowButton } from "@/components/motion/expanding-arrow-button";
export function ExpandingArrowButtonPreview() {
return (
<div className="flex items-center justify-center">
<ExpandingArrowButton>Book a demo</ExpandingArrowButton>
</div>
);
}
TSXcomponents/motion/expanding-arrow-button.tsx
"use client";
// beui.dev/components/motion/expanding-arrow-button
import {
motion,
useReducedMotion,
type HTMLMotionProps,
} from "motion/react";
import {
forwardRef,
useState,
type FocusEvent,
type MouseEvent,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface ExpandingArrowButtonProps extends Omit<
HTMLMotionProps<"button">,
"children"
> {
children: ReactNode;
accentClassName?: string;
labelClassName?: string;
}
const ARROW_OPACITY = [1, 0.78, 0.54, 0.32, 0.16] as const;
function DottedChevron({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 20 28"
fill="none"
aria-hidden="true"
className={className}
>
<circle cx="4" cy="4" r="2" fill="currentColor" />
<circle cx="10" cy="9" r="2" fill="currentColor" />
<circle cx="16" cy="14" r="2" fill="currentColor" />
<circle cx="10" cy="19" r="2" fill="currentColor" />
<circle cx="4" cy="24" r="2" fill="currentColor" />
</svg>
);
}
export const ExpandingArrowButton = forwardRef<
HTMLButtonElement,
ExpandingArrowButtonProps
>(function ExpandingArrowButton(
{
children,
className,
accentClassName,
labelClassName,
disabled,
onMouseEnter,
onMouseLeave,
onFocus,
onBlur,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const active = !disabled && ((canHover && hovered) || focused);
const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const handleMouseEnter = (event: MouseEvent<HTMLButtonElement>) => {
setHovered(true);
onMouseEnter?.(event);
};
const handleMouseLeave = (event: MouseEvent<HTMLButtonElement>) => {
setHovered(false);
onMouseLeave?.(event);
};
const handleFocus = (event: FocusEvent<HTMLButtonElement>) => {
setFocused(true);
onFocus?.(event);
};
const handleBlur = (event: FocusEvent<HTMLButtonElement>) => {
setFocused(false);
onBlur?.(event);
};
return (
<motion.button
ref={ref}
type="button"
disabled={disabled}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleFocus}
onBlur={handleBlur}
whileTap={reduce || disabled ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-flex h-16 min-w-72 items-center overflow-hidden rounded-[22px] bg-neutral-950 p-1.5 text-white select-none",
"outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...rest}
>
<motion.span
layout="size"
aria-hidden="true"
transition={layoutTransition}
style={{
width: active ? "calc(100% - 12px)" : 52,
borderRadius: 16,
}}
className={cn(
"absolute inset-y-1.5 left-1.5 z-10 overflow-hidden bg-lime-300 text-neutral-950",
accentClassName,
)}
>
<motion.span
animate={{ opacity: active ? 0 : 1 }}
transition={{ duration: reduce ? 0 : 0.1, ease: EASE_OUT }}
className="absolute inset-0 grid place-items-center"
>
<DottedChevron className="h-7 w-5" />
</motion.span>
<span className="absolute inset-0 flex items-center justify-around px-3">
{ARROW_OPACITY.map((opacity, index) => (
<motion.span
key={opacity}
animate={{
opacity: active ? 1 : 0,
transform:
active && !reduce ? "translateX(0px)" : "translateX(-6px)",
}}
transition={{
duration: reduce ? 0 : 0.18,
delay: active && !reduce ? 0.04 + index * 0.025 : 0,
ease: EASE_OUT,
}}
style={{ color: `rgb(10 10 10 / ${opacity})` }}
className="inline-grid place-items-center"
>
<DottedChevron className="h-7 w-5" />
</motion.span>
))}
</span>
</motion.span>
<motion.span
animate={{
opacity: active ? 0 : 1,
transform:
active && !reduce ? "translateX(6px)" : "translateX(0px)",
}}
transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}
className={cn(
"relative z-0 ml-[76px] mr-5 whitespace-nowrap text-lg font-medium tracking-[-0.02em]",
labelClassName,
)}
>
{children}
</motion.span>
</motion.button>
);
});
Hold Action Button
hold-action-button.tsxHold to complete with a vertical or horizontal liquid fill; release early to cancel.
Release early to cancel
TSXcomponents/previews/motion/hold-action-button.preview.tsx
"use client";
import { useState } from "react";
import { HoldActionButton } from "@/components/motion/hold-action-button";
export function HoldActionButtonPreview() {
const [confirmed, setConfirmed] = useState(false);
return (
<div className="flex flex-col items-center justify-center gap-3">
<HoldActionButton
onHoldComplete={() => {
setConfirmed(true);
window.setTimeout(() => setConfirmed(false), 1800);
}}
>
Hold for vertical fill
</HoldActionButton>
<HoldActionButton
type="horizontal"
onHoldComplete={() => {
setConfirmed(true);
window.setTimeout(() => setConfirmed(false), 1800);
}}
>
Hold for horizontal fill
</HoldActionButton>
<p className="h-4 text-xs text-muted-foreground" aria-live="polite">
{confirmed ? "Action confirmed" : "Release early to cancel"}
</p>
</div>
);
}
TSXcomponents/motion/hold-action-button.tsx
"use client";
// beui.dev/components/motion/expanding-arrow-button
import { motion, useReducedMotion, type HTMLMotionProps } from "motion/react";
import {
forwardRef,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface HoldActionButtonProps extends Omit<
HTMLMotionProps<"button">,
| "children"
| "type"
| "onClick"
| "onPointerDown"
| "onPointerUp"
| "onPointerCancel"
| "onPointerLeave"
| "onKeyDown"
| "onKeyUp"
> {
children: ReactNode;
type?: "vertical" | "horizontal";
holdingLabel?: ReactNode;
completeLabel?: ReactNode;
holdDuration?: number;
onHoldComplete?: () => void;
fillClassName?: string;
labelClassName?: string;
}
export const HoldActionButton = forwardRef<
HTMLButtonElement,
HoldActionButtonProps
>(function HoldActionButton(
{
children,
type = "vertical",
holdingLabel = "Keep holding",
completeLabel = "Done",
holdDuration = 1600,
onHoldComplete,
fillClassName,
labelClassName,
className,
disabled,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const completedRef = useRef(false);
const [holding, setHolding] = useState(false);
const [completed, setCompleted] = useState(false);
const startHold = () => {
if (disabled || holding) return;
completedRef.current = false;
setCompleted(false);
setHolding(true);
};
const cancelHold = () => {
setHolding(false);
setCompleted(false);
};
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
startHold();
};
const handlePointerUp = (event: PointerEvent<HTMLButtonElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
cancelHold();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === " " || event.key === "Enter") && !event.repeat) {
event.preventDefault();
startHold();
}
};
const handleKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
cancelHold();
}
};
const handleFillComplete = () => {
if (!holding || completedRef.current) return;
completedRef.current = true;
setCompleted(true);
onHoldComplete?.();
};
const active = holding || completed;
const activeTransform =
type === "horizontal" ? "translateX(0%)" : "translateY(0%)";
const idleTransform =
type === "horizontal" ? "translateX(-100%)" : "translateY(115%)";
return (
<motion.button
ref={ref}
type="button"
disabled={disabled}
aria-label={typeof children === "string" ? children : undefined}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={cancelHold}
onPointerLeave={cancelHold}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onContextMenu={(event) => event.preventDefault()}
whileTap={reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-grid h-16 min-w-72 touch-none select-none place-items-center overflow-hidden rounded-[22px] bg-primary px-8 text-primary-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
initial={false}
animate={
reduce
? { opacity: active ? 1 : 0, transform: "none" }
: {
opacity: 1,
transform: active ? activeTransform : idleTransform,
}
}
transition={
active
? { duration: holdDuration / 1000, ease: "linear" }
: { duration: reduce ? 0.15 : 0.24, ease: EASE_OUT }
}
onAnimationComplete={handleFillComplete}
className={cn(
"absolute inset-0 bg-sky-400 will-change-[opacity,transform]",
fillClassName,
)}
>
{!reduce ? (
type === "horizontal" ? (
<motion.svg
viewBox="0 0 24 240"
preserveAspectRatio="none"
aria-hidden="true"
animate={{
transform: active ? "translateY(-50%)" : "translateY(0%)",
}}
transition={{
duration: 1.1,
ease: "linear",
repeat: active ? Number.POSITIVE_INFINITY : 0,
}}
className="absolute -right-5 top-0 h-[200%] w-6 text-sky-400"
>
<path
d="M0 0h12C2 20 2 40 12 60s10 40 0 60-10 40 0 60 10 40 0 60H0Z"
fill="currentColor"
/>
</motion.svg>
) : (
<motion.svg
viewBox="0 0 240 24"
preserveAspectRatio="none"
aria-hidden="true"
animate={{
transform: active ? "translateX(-50%)" : "translateX(0%)",
}}
transition={{
duration: 1.1,
ease: "linear",
repeat: active ? Number.POSITIVE_INFINITY : 0,
}}
className="absolute -top-5 left-0 h-6 w-[200%] text-sky-400"
>
<path
d="M0 12C20 2 40 2 60 12s40 10 60 0 40-10 60 0 40 10 60 0v12H0Z"
fill="currentColor"
/>
</motion.svg>
)
) : null}
</motion.span>
<span
className={cn(
"relative z-10 grid place-items-center text-base font-medium tracking-[-0.01em]",
labelClassName,
)}
>
<motion.span
animate={{ opacity: active ? 0 : 1 }}
transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}
className="col-start-1 row-start-1"
>
{children}
</motion.span>
<motion.span
aria-hidden={!holding || completed}
animate={{ opacity: holding && !completed ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}
className="col-start-1 row-start-1"
>
{holdingLabel}
</motion.span>
<motion.span
aria-hidden={!completed}
animate={{ opacity: completed ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}
className="col-start-1 row-start-1"
>
{completeLabel}
</motion.span>
</span>
</motion.button>
);
});
Install
$ bunx --bun shadcn add @beui/hold-action-button
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx 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;
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/hold-action-button.tsx
"use client";
// beui.dev/components/motion/expanding-arrow-button
import { motion, useReducedMotion, type HTMLMotionProps } from "motion/react";
import {
forwardRef,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface HoldActionButtonProps extends Omit<
HTMLMotionProps<"button">,
| "children"
| "type"
| "onClick"
| "onPointerDown"
| "onPointerUp"
| "onPointerCancel"
| "onPointerLeave"
| "onKeyDown"
| "onKeyUp"
> {
children: ReactNode;
type?: "vertical" | "horizontal";
holdingLabel?: ReactNode;
completeLabel?: ReactNode;
holdDuration?: number;
onHoldComplete?: () => void;
fillClassName?: string;
labelClassName?: string;
}
export const HoldActionButton = forwardRef<
HTMLButtonElement,
HoldActionButtonProps
>(function HoldActionButton(
{
children,
type = "vertical",
holdingLabel = "Keep holding",
completeLabel = "Done",
holdDuration = 1600,
onHoldComplete,
fillClassName,
labelClassName,
className,
disabled,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const completedRef = useRef(false);
const [holding, setHolding] = useState(false);
const [completed, setCompleted] = useState(false);
const startHold = () => {
if (disabled || holding) return;
completedRef.current = false;
setCompleted(false);
setHolding(true);
};
const cancelHold = () => {
setHolding(false);
setCompleted(false);
};
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
startHold();
};
const handlePointerUp = (event: PointerEvent<HTMLButtonElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
cancelHold();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === " " || event.key === "Enter") && !event.repeat) {
event.preventDefault();
startHold();
}
};
const handleKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
cancelHold();
}
};
const handleFillComplete = () => {
if (!holding || completedRef.current) return;
completedRef.current = true;
setCompleted(true);
onHoldComplete?.();
};
const active = holding || completed;
const activeTransform =
type === "horizontal" ? "translateX(0%)" : "translateY(0%)";
const idleTransform =
type === "horizontal" ? "translateX(-100%)" : "translateY(115%)";
return (
<motion.button
ref={ref}
type="button"
disabled={disabled}
aria-label={typeof children === "string" ? children : undefined}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={cancelHold}
onPointerLeave={cancelHold}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onContextMenu={(event) => event.preventDefault()}
whileTap={reduce || disabled ? undefined : { scale: 0.98 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-grid h-16 min-w-72 touch-none select-none place-items-center overflow-hidden rounded-[22px] bg-primary px-8 text-primary-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
initial={false}
animate={
reduce
? { opacity: active ? 1 : 0, transform: "none" }
: {
opacity: 1,
transform: active ? activeTransform : idleTransform,
}
}
transition={
active
? { duration: holdDuration / 1000, ease: "linear" }
: { duration: reduce ? 0.15 : 0.24, ease: EASE_OUT }
}
onAnimationComplete={handleFillComplete}
className={cn(
"absolute inset-0 bg-sky-400 will-change-[opacity,transform]",
fillClassName,
)}
>
{!reduce ? (
type === "horizontal" ? (
<motion.svg
viewBox="0 0 24 240"
preserveAspectRatio="none"
aria-hidden="true"
animate={{
transform: active ? "translateY(-50%)" : "translateY(0%)",
}}
transition={{
duration: 1.1,
ease: "linear",
repeat: active ? Number.POSITIVE_INFINITY : 0,
}}
className="absolute -right-5 top-0 h-[200%] w-6 text-sky-400"
>
<path
d="M0 0h12C2 20 2 40 12 60s10 40 0 60-10 40 0 60 10 40 0 60H0Z"
fill="currentColor"
/>
</motion.svg>
) : (
<motion.svg
viewBox="0 0 240 24"
preserveAspectRatio="none"
aria-hidden="true"
animate={{
transform: active ? "translateX(-50%)" : "translateX(0%)",
}}
transition={{
duration: 1.1,
ease: "linear",
repeat: active ? Number.POSITIVE_INFINITY : 0,
}}
className="absolute -top-5 left-0 h-6 w-[200%] text-sky-400"
>
<path
d="M0 12C20 2 40 2 60 12s40 10 60 0 40-10 60 0 40 10 60 0v12H0Z"
fill="currentColor"
/>
</motion.svg>
)
) : null}
</motion.span>
<span
className={cn(
"relative z-10 grid place-items-center text-base font-medium tracking-[-0.01em]",
labelClassName,
)}
>
<motion.span
animate={{ opacity: active ? 0 : 1 }}
transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}
className="col-start-1 row-start-1"
>
{children}
</motion.span>
<motion.span
aria-hidden={!holding || completed}
animate={{ opacity: holding && !completed ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}
className="col-start-1 row-start-1"
>
{holdingLabel}
</motion.span>
<motion.span
aria-hidden={!completed}
animate={{ opacity: completed ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}
className="col-start-1 row-start-1"
>
{completeLabel}
</motion.span>
</span>
</motion.button>
);
});
Slide Action Button
slide-action-button.tsxDrag the thumb to the end to confirm an action; release early to spring back.
Drag the arrow to the end
TSXcomponents/previews/motion/slide-action-button.preview.tsx
"use client";
import { useState } from "react";
import { SlideActionButton } from "@/components/motion/slide-action-button";
export function SlideActionButtonPreview() {
const [continued, setContinued] = useState(false);
return (
<div className="flex flex-col items-center gap-3">
<SlideActionButton
completeLabel="Ready"
onComplete={() => {
setContinued(true);
window.setTimeout(() => setContinued(false), 1800);
}}
>
Slide to continue
</SlideActionButton>
<p className="h-4 text-xs text-muted-foreground" aria-live="polite">
{continued ? "Action completed" : "Drag the arrow to the end"}
</p>
</div>
);
}
TSXcomponents/motion/slide-action-button.tsx
"use client";
// beui.dev/components/motion/expanding-arrow-button
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SlideActionButtonProps extends Omit<
HTMLAttributes<HTMLDivElement>,
"children"
> {
children: ReactNode;
completeLabel?: ReactNode;
threshold?: number;
resetDelay?: number;
onComplete?: () => void;
thumbClassName?: string;
fillClassName?: string;
}
export function SlideActionButton({
children,
completeLabel = "Complete",
threshold = 0.82,
resetDelay = 1200,
onComplete,
thumbClassName,
fillClassName,
className,
...rest
}: SlideActionButtonProps) {
const reduce = useReducedMotion();
const trackRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLButtonElement>(null);
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const completedRef = useRef(false);
const x = useMotionValue(0);
const [maxDistance, setMaxDistance] = useState(0);
const [completed, setCompleted] = useState(false);
const safeDistance = Math.max(maxDistance, 1);
const dragProgress = useTransform(x, [0, safeDistance], [0, 1]);
const fillProgress = useTransform(x, [0, safeDistance], [0, 1]);
const labelOpacity = useTransform(
x,
[0, safeDistance * 0.35, safeDistance * 0.65],
[1, 0.75, 0],
);
const iconPath = useTransform(
dragProgress,
[0, 0.5, 1],
[
"M 8 5 L 15 12 L 8 19",
"M 7 8 L 12 14 L 17 10",
"M 5 12 L 10 17 L 19 7",
],
);
useLayoutEffect(() => {
const track = trackRef.current;
const thumb = thumbRef.current;
if (!track || !thumb) return;
const measure = () => {
setMaxDistance(Math.max(track.clientWidth - thumb.clientWidth - 8, 0));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(track);
observer.observe(thumb);
return () => observer.disconnect();
}, []);
useEffect(
() => () => {
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
},
[],
);
const moveTo = (target: number) => {
if (reduce) {
x.set(target);
return;
}
animate(x, target, SPRING_LAYOUT);
};
const reset = () => {
completedRef.current = false;
setCompleted(false);
moveTo(0);
};
const complete = () => {
if (completedRef.current || maxDistance === 0) return;
completedRef.current = true;
setCompleted(true);
moveTo(maxDistance);
onComplete?.();
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
resetTimerRef.current = setTimeout(reset, resetDelay);
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
complete();
};
return (
<div
ref={trackRef}
className={cn(
"relative h-16 w-72 overflow-hidden rounded-[22px] bg-primary/10 p-1",
"ring-1 ring-primary/10",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
style={{ scaleX: fillProgress }}
className={cn(
"absolute inset-0 origin-left bg-primary will-change-transform",
fillClassName,
)}
/>
<motion.span
aria-hidden="true"
style={{ opacity: labelOpacity }}
className="pointer-events-none absolute inset-0 grid place-items-center pl-10 text-sm font-medium text-foreground"
>
{children}
</motion.span>
<motion.span
aria-live="polite"
animate={{ opacity: completed ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.15, ease: EASE_OUT }}
className="pointer-events-none absolute inset-0 grid place-items-center text-sm font-medium text-primary-foreground"
>
{completed ? completeLabel : null}
</motion.span>
<motion.button
ref={thumbRef}
type="button"
aria-label={typeof children === "string" ? children : "Slide action"}
drag={completed ? false : "x"}
dragConstraints={{ left: 0, right: maxDistance }}
dragElastic={0}
dragMomentum={false}
style={{ x }}
onDragEnd={() => {
if (x.get() >= maxDistance * threshold) complete();
else moveTo(0);
}}
onKeyDown={handleKeyDown}
whileTap={reduce || completed ? undefined : { scale: 0.94 }}
transition={SPRING_PRESS}
className={cn(
"relative z-10 grid size-14 touch-none cursor-grab place-items-center rounded-[18px] bg-primary text-primary-foreground shadow-sm",
"outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
completed && "cursor-default bg-background text-foreground",
thumbClassName,
)}
>
<motion.svg viewBox="0 0 24 24" aria-hidden="true" className="size-5">
<motion.path
d={iconPath}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</motion.svg>
</motion.button>
</div>
);
}
Install
$ bunx --bun shadcn add @beui/slide-action-button
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx 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;
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/slide-action-button.tsx
"use client";
// beui.dev/components/motion/expanding-arrow-button
import {
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SlideActionButtonProps extends Omit<
HTMLAttributes<HTMLDivElement>,
"children"
> {
children: ReactNode;
completeLabel?: ReactNode;
threshold?: number;
resetDelay?: number;
onComplete?: () => void;
thumbClassName?: string;
fillClassName?: string;
}
export function SlideActionButton({
children,
completeLabel = "Complete",
threshold = 0.82,
resetDelay = 1200,
onComplete,
thumbClassName,
fillClassName,
className,
...rest
}: SlideActionButtonProps) {
const reduce = useReducedMotion();
const trackRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLButtonElement>(null);
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const completedRef = useRef(false);
const x = useMotionValue(0);
const [maxDistance, setMaxDistance] = useState(0);
const [completed, setCompleted] = useState(false);
const safeDistance = Math.max(maxDistance, 1);
const dragProgress = useTransform(x, [0, safeDistance], [0, 1]);
const fillProgress = useTransform(x, [0, safeDistance], [0, 1]);
const labelOpacity = useTransform(
x,
[0, safeDistance * 0.35, safeDistance * 0.65],
[1, 0.75, 0],
);
const iconPath = useTransform(
dragProgress,
[0, 0.5, 1],
[
"M 8 5 L 15 12 L 8 19",
"M 7 8 L 12 14 L 17 10",
"M 5 12 L 10 17 L 19 7",
],
);
useLayoutEffect(() => {
const track = trackRef.current;
const thumb = thumbRef.current;
if (!track || !thumb) return;
const measure = () => {
setMaxDistance(Math.max(track.clientWidth - thumb.clientWidth - 8, 0));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(track);
observer.observe(thumb);
return () => observer.disconnect();
}, []);
useEffect(
() => () => {
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
},
[],
);
const moveTo = (target: number) => {
if (reduce) {
x.set(target);
return;
}
animate(x, target, SPRING_LAYOUT);
};
const reset = () => {
completedRef.current = false;
setCompleted(false);
moveTo(0);
};
const complete = () => {
if (completedRef.current || maxDistance === 0) return;
completedRef.current = true;
setCompleted(true);
moveTo(maxDistance);
onComplete?.();
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
resetTimerRef.current = setTimeout(reset, resetDelay);
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
complete();
};
return (
<div
ref={trackRef}
className={cn(
"relative h-16 w-72 overflow-hidden rounded-[22px] bg-primary/10 p-1",
"ring-1 ring-primary/10",
className,
)}
{...rest}
>
<motion.span
aria-hidden="true"
style={{ scaleX: fillProgress }}
className={cn(
"absolute inset-0 origin-left bg-primary will-change-transform",
fillClassName,
)}
/>
<motion.span
aria-hidden="true"
style={{ opacity: labelOpacity }}
className="pointer-events-none absolute inset-0 grid place-items-center pl-10 text-sm font-medium text-foreground"
>
{children}
</motion.span>
<motion.span
aria-live="polite"
animate={{ opacity: completed ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.15, ease: EASE_OUT }}
className="pointer-events-none absolute inset-0 grid place-items-center text-sm font-medium text-primary-foreground"
>
{completed ? completeLabel : null}
</motion.span>
<motion.button
ref={thumbRef}
type="button"
aria-label={typeof children === "string" ? children : "Slide action"}
drag={completed ? false : "x"}
dragConstraints={{ left: 0, right: maxDistance }}
dragElastic={0}
dragMomentum={false}
style={{ x }}
onDragEnd={() => {
if (x.get() >= maxDistance * threshold) complete();
else moveTo(0);
}}
onKeyDown={handleKeyDown}
whileTap={reduce || completed ? undefined : { scale: 0.94 }}
transition={SPRING_PRESS}
className={cn(
"relative z-10 grid size-14 touch-none cursor-grab place-items-center rounded-[18px] bg-primary text-primary-foreground shadow-sm",
"outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
completed && "cursor-default bg-background text-foreground",
thumbClassName,
)}
>
<motion.svg viewBox="0 0 24 24" aria-hidden="true" className="size-5">
<motion.path
d={iconPath}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</motion.svg>
</motion.button>
</div>
);
}
API Reference
completeLabel?anyCompletethreshold?number0.82resetDelay?number1200onComplete?(() => void)—thumbClassName?string—fillClassName?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.