Scroll Animation
Scroll-driven motion: a Lenis smooth-scroll provider and a reading-progress indicator that reads from it.
Smooth Scroll
smooth-scroll.tsxSmooth-scroll provider over Lenis with a useSmoothScroll hook exposing scroll offset, progress and velocity. Reduced-motion safe.
"use client";
import { ArrowUp } from "lucide-react";
import { SmoothScroll, useSmoothScroll } from "@/components/motion/smooth-scroll";
// In production <SmoothScroll> wraps the page (root). Here it runs in contained
// mode (root={false}) so the box itself smooth-scrolls — the same engine, and
// the button uses the useSmoothScroll() hook to glide back to the top.
const SECTIONS = Array.from({ length: 16 }, (_, i) => i + 1);
function ScrollTopButton() {
const { scrollTo } = useSmoothScroll();
return (
<button
type="button"
onClick={() => scrollTo(0)}
className="sticky bottom-3 left-[calc(100%-3rem)] z-10 grid size-9 place-items-center rounded-full border border-border bg-background/80 text-foreground backdrop-blur transition-colors hover:bg-background"
aria-label="Scroll to top"
>
<ArrowUp className="size-4" />
</button>
);
}
export function SmoothScrollPreview() {
return (
<SmoothScroll
root={false}
className="h-64 w-full max-w-lg overflow-y-auto scrollbar-hide rounded-2xl border border-border bg-card"
>
<div className="space-y-3 p-4">
{SECTIONS.map((n) => (
<div
key={`section-${n}`}
className="rounded-lg bg-muted/60 px-3 py-4 text-sm text-muted-foreground"
>
Section {n}
</div>
))}
</div>
<ScrollTopButton />
</SmoothScroll>
);
}
"use client";
// beui.dev/components/motion/scroll-animation
import type Lenis from "lenis";
import { ReactLenis, useLenis } from "lenis/react";
import { type MotionValue, useMotionValue, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
// Lenis' own expo-out curve — the canonical smooth-scroll easing. Kept as a
// named local fn (not a lib/ease token) because tokens are bezier control
// points for the motion lib, while Lenis needs a (t) => number easing fn.
const EASE_SCROLL = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t));
export type ScrollTarget = number | string | HTMLElement;
export type ScrollToOptions = {
offset?: number;
immediate?: boolean;
duration?: number;
};
export type SmoothScrollApi = {
/** Underlying Lenis instance, or null on the reduced-motion / native path. */
lenis: Lenis | null;
/** Current scroll offset in px. */
scrollY: MotionValue<number>;
/** Scroll position as 0..1 of the scrollable height. */
progress: MotionValue<number>;
/** Signed scroll velocity (px/frame); drives velocity-based effects. */
velocity: MotionValue<number>;
/** Programmatic smooth scroll. Respects reduced motion (jumps instantly). */
scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;
};
const SmoothScrollContext = createContext<SmoothScrollApi | null>(null);
export interface SmoothScrollProps {
children: ReactNode;
/** Drive the page (window) when true, or a contained scroll area when false. */
root?: boolean;
/** Smoothing factor; lower is smoother and heavier. */
lerp?: number;
/** Wheel / programmatic ease duration in seconds. */
duration?: number;
orientation?: "vertical" | "horizontal";
/** Wheel scroll speed multiplier. */
wheelMultiplier?: number;
/** Smooth touch scrolling. Off by default — native momentum is good on mobile. */
touch?: boolean;
className?: string;
}
type ScrollSource = Window | HTMLElement;
function readMetrics(target: ScrollSource) {
if (target instanceof Window) {
const max = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight,
);
return { y: window.scrollY, max };
}
return {
y: target.scrollTop,
max: Math.max(0, target.scrollHeight - target.clientHeight),
};
}
function resolveTop(
target: ScrollTarget,
source: ScrollSource,
offset = 0,
): number {
if (typeof target === "number") return target + offset;
if (source instanceof Window) {
const el =
typeof target === "string" ? document.querySelector(target) : target;
if (!el) return window.scrollY;
return el.getBoundingClientRect().top + window.scrollY + offset;
}
const el =
typeof target === "string" ? source.querySelector(target) : target;
if (!(el instanceof HTMLElement)) return source.scrollTop;
return el.offsetTop + offset;
}
/** Pushes Lenis' live scroll state into the shared motion values. */
function LenisBridge({
scrollY,
progress,
velocity,
lenisRef,
}: {
scrollY: MotionValue<number>;
progress: MotionValue<number>;
velocity: MotionValue<number>;
lenisRef: { current: Lenis | null };
}) {
const lenis = useLenis((instance) => {
scrollY.set(instance.scroll);
progress.set(instance.progress);
velocity.set(instance.velocity);
});
useEffect(() => {
lenisRef.current = lenis ?? null;
return () => {
lenisRef.current = null;
};
}, [lenis, lenisRef]);
return null;
}
/** Native scroll listener for the reduced-motion path and the no-provider fallback. */
function useNativeScrollSync(
enabled: boolean,
getTarget: () => ScrollSource | null,
scrollY: MotionValue<number>,
progress: MotionValue<number>,
velocity: MotionValue<number>,
) {
useEffect(() => {
if (!enabled) return;
const target = getTarget();
if (!target) return;
let lastY = readMetrics(target).y;
let lastT = performance.now();
const onScroll = () => {
const { y, max } = readMetrics(target);
const now = performance.now();
const dt = now - lastT || 16;
scrollY.set(y);
progress.set(max > 0 ? y / max : 0);
velocity.set(((y - lastY) / dt) * 16);
lastY = y;
lastT = now;
};
onScroll();
target.addEventListener("scroll", onScroll, { passive: true });
return () => target.removeEventListener("scroll", onScroll);
}, [enabled, getTarget, scrollY, progress, velocity]);
}
export function SmoothScroll({
children,
root = true,
lerp = 0.1,
duration = 1.2,
orientation = "vertical",
wheelMultiplier = 1,
touch = false,
className,
}: SmoothScrollProps) {
const reduce = useReducedMotion();
const scrollY = useMotionValue(0);
const progress = useMotionValue(0);
const velocity = useMotionValue(0);
const lenisRef = useRef<Lenis | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const nativeSource = useCallback(
(): ScrollSource | null => (root ? window : containerRef.current),
[root],
);
const scrollTo = useCallback(
(target: ScrollTarget, options?: ScrollToOptions) => {
const lenis = lenisRef.current;
if (lenis && !reduce) {
lenis.scrollTo(target, {
offset: options?.offset,
duration: options?.duration,
immediate: options?.immediate,
});
return;
}
const source = nativeSource();
const behavior = reduce || options?.immediate ? "auto" : "smooth";
const top = resolveTop(target, source ?? window, options?.offset);
(source ?? window).scrollTo({ top, behavior });
},
[reduce, nativeSource],
);
// Reduced motion drives the native listener; the Lenis path leaves it
// disabled and lets LenisBridge feed the values instead.
useNativeScrollSync(!!reduce, nativeSource, scrollY, progress, velocity);
const api = useMemo<SmoothScrollApi>(
() => ({ lenis: lenisRef.current, scrollY, progress, velocity, scrollTo }),
[scrollY, progress, velocity, scrollTo],
);
if (reduce) {
return (
<SmoothScrollContext.Provider value={api}>
<div ref={containerRef} className={className}>
{children}
</div>
</SmoothScrollContext.Provider>
);
}
return (
<SmoothScrollContext.Provider value={api}>
<ReactLenis
root={root}
className={className}
options={{
lerp,
duration,
orientation,
wheelMultiplier,
smoothWheel: true,
syncTouch: touch,
easing: EASE_SCROLL,
}}
>
<LenisBridge
scrollY={scrollY}
progress={progress}
velocity={velocity}
lenisRef={lenisRef}
/>
{children}
</ReactLenis>
</SmoothScrollContext.Provider>
);
}
/**
* Read the page's smooth-scroll state. Inside <SmoothScroll> it returns the
* shared motion values; outside it falls back to a native window scroll
* listener so scroll-driven components still work without the provider.
*/
export function useSmoothScroll(): SmoothScrollApi {
const ctx = useContext(SmoothScrollContext);
const scrollY = useMotionValue(0);
const progress = useMotionValue(0);
const velocity = useMotionValue(0);
const windowSource = useCallback((): ScrollSource => window, []);
useNativeScrollSync(ctx === null, windowSource, scrollY, progress, velocity);
const scrollTo = useCallback((target: ScrollTarget, options?: ScrollToOptions) => {
window.scrollTo({
top: resolveTop(target, window, options?.offset),
behavior: options?.immediate ? "auto" : "smooth",
});
}, []);
const fallback = useMemo<SmoothScrollApi>(
() => ({ lenis: null, scrollY, progress, velocity, scrollTo }),
[scrollY, progress, velocity, scrollTo],
);
return ctx ?? fallback;
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i lenis lucide-react motionCopy the source code
"use client";
// beui.dev/components/motion/scroll-animation
import type Lenis from "lenis";
import { ReactLenis, useLenis } from "lenis/react";
import { type MotionValue, useMotionValue, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
// Lenis' own expo-out curve — the canonical smooth-scroll easing. Kept as a
// named local fn (not a lib/ease token) because tokens are bezier control
// points for the motion lib, while Lenis needs a (t) => number easing fn.
const EASE_SCROLL = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t));
export type ScrollTarget = number | string | HTMLElement;
export type ScrollToOptions = {
offset?: number;
immediate?: boolean;
duration?: number;
};
export type SmoothScrollApi = {
/** Underlying Lenis instance, or null on the reduced-motion / native path. */
lenis: Lenis | null;
/** Current scroll offset in px. */
scrollY: MotionValue<number>;
/** Scroll position as 0..1 of the scrollable height. */
progress: MotionValue<number>;
/** Signed scroll velocity (px/frame); drives velocity-based effects. */
velocity: MotionValue<number>;
/** Programmatic smooth scroll. Respects reduced motion (jumps instantly). */
scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;
};
const SmoothScrollContext = createContext<SmoothScrollApi | null>(null);
export interface SmoothScrollProps {
children: ReactNode;
/** Drive the page (window) when true, or a contained scroll area when false. */
root?: boolean;
/** Smoothing factor; lower is smoother and heavier. */
lerp?: number;
/** Wheel / programmatic ease duration in seconds. */
duration?: number;
orientation?: "vertical" | "horizontal";
/** Wheel scroll speed multiplier. */
wheelMultiplier?: number;
/** Smooth touch scrolling. Off by default — native momentum is good on mobile. */
touch?: boolean;
className?: string;
}
type ScrollSource = Window | HTMLElement;
function readMetrics(target: ScrollSource) {
if (target instanceof Window) {
const max = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight,
);
return { y: window.scrollY, max };
}
return {
y: target.scrollTop,
max: Math.max(0, target.scrollHeight - target.clientHeight),
};
}
function resolveTop(
target: ScrollTarget,
source: ScrollSource,
offset = 0,
): number {
if (typeof target === "number") return target + offset;
if (source instanceof Window) {
const el =
typeof target === "string" ? document.querySelector(target) : target;
if (!el) return window.scrollY;
return el.getBoundingClientRect().top + window.scrollY + offset;
}
const el =
typeof target === "string" ? source.querySelector(target) : target;
if (!(el instanceof HTMLElement)) return source.scrollTop;
return el.offsetTop + offset;
}
/** Pushes Lenis' live scroll state into the shared motion values. */
function LenisBridge({
scrollY,
progress,
velocity,
lenisRef,
}: {
scrollY: MotionValue<number>;
progress: MotionValue<number>;
velocity: MotionValue<number>;
lenisRef: { current: Lenis | null };
}) {
const lenis = useLenis((instance) => {
scrollY.set(instance.scroll);
progress.set(instance.progress);
velocity.set(instance.velocity);
});
useEffect(() => {
lenisRef.current = lenis ?? null;
return () => {
lenisRef.current = null;
};
}, [lenis, lenisRef]);
return null;
}
/** Native scroll listener for the reduced-motion path and the no-provider fallback. */
function useNativeScrollSync(
enabled: boolean,
getTarget: () => ScrollSource | null,
scrollY: MotionValue<number>,
progress: MotionValue<number>,
velocity: MotionValue<number>,
) {
useEffect(() => {
if (!enabled) return;
const target = getTarget();
if (!target) return;
let lastY = readMetrics(target).y;
let lastT = performance.now();
const onScroll = () => {
const { y, max } = readMetrics(target);
const now = performance.now();
const dt = now - lastT || 16;
scrollY.set(y);
progress.set(max > 0 ? y / max : 0);
velocity.set(((y - lastY) / dt) * 16);
lastY = y;
lastT = now;
};
onScroll();
target.addEventListener("scroll", onScroll, { passive: true });
return () => target.removeEventListener("scroll", onScroll);
}, [enabled, getTarget, scrollY, progress, velocity]);
}
export function SmoothScroll({
children,
root = true,
lerp = 0.1,
duration = 1.2,
orientation = "vertical",
wheelMultiplier = 1,
touch = false,
className,
}: SmoothScrollProps) {
const reduce = useReducedMotion();
const scrollY = useMotionValue(0);
const progress = useMotionValue(0);
const velocity = useMotionValue(0);
const lenisRef = useRef<Lenis | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const nativeSource = useCallback(
(): ScrollSource | null => (root ? window : containerRef.current),
[root],
);
const scrollTo = useCallback(
(target: ScrollTarget, options?: ScrollToOptions) => {
const lenis = lenisRef.current;
if (lenis && !reduce) {
lenis.scrollTo(target, {
offset: options?.offset,
duration: options?.duration,
immediate: options?.immediate,
});
return;
}
const source = nativeSource();
const behavior = reduce || options?.immediate ? "auto" : "smooth";
const top = resolveTop(target, source ?? window, options?.offset);
(source ?? window).scrollTo({ top, behavior });
},
[reduce, nativeSource],
);
// Reduced motion drives the native listener; the Lenis path leaves it
// disabled and lets LenisBridge feed the values instead.
useNativeScrollSync(!!reduce, nativeSource, scrollY, progress, velocity);
const api = useMemo<SmoothScrollApi>(
() => ({ lenis: lenisRef.current, scrollY, progress, velocity, scrollTo }),
[scrollY, progress, velocity, scrollTo],
);
if (reduce) {
return (
<SmoothScrollContext.Provider value={api}>
<div ref={containerRef} className={className}>
{children}
</div>
</SmoothScrollContext.Provider>
);
}
return (
<SmoothScrollContext.Provider value={api}>
<ReactLenis
root={root}
className={className}
options={{
lerp,
duration,
orientation,
wheelMultiplier,
smoothWheel: true,
syncTouch: touch,
easing: EASE_SCROLL,
}}
>
<LenisBridge
scrollY={scrollY}
progress={progress}
velocity={velocity}
lenisRef={lenisRef}
/>
{children}
</ReactLenis>
</SmoothScrollContext.Provider>
);
}
/**
* Read the page's smooth-scroll state. Inside <SmoothScroll> it returns the
* shared motion values; outside it falls back to a native window scroll
* listener so scroll-driven components still work without the provider.
*/
export function useSmoothScroll(): SmoothScrollApi {
const ctx = useContext(SmoothScrollContext);
const scrollY = useMotionValue(0);
const progress = useMotionValue(0);
const velocity = useMotionValue(0);
const windowSource = useCallback((): ScrollSource => window, []);
useNativeScrollSync(ctx === null, windowSource, scrollY, progress, velocity);
const scrollTo = useCallback((target: ScrollTarget, options?: ScrollToOptions) => {
window.scrollTo({
top: resolveTop(target, window, options?.offset),
behavior: options?.immediate ? "auto" : "smooth",
});
}, []);
const fallback = useMemo<SmoothScrollApi>(
() => ({ lenis: null, scrollY, progress, velocity, scrollTo }),
[scrollY, progress, velocity, scrollTo],
);
return ctx ?? fallback;
}
API Reference
root?booleanDrive the page (window) when true, or a contained scroll area when false.
truelerp?numberSmoothing factor; lower is smoother and heavier.
0.1duration?numberWheel / programmatic ease duration in seconds.
1.2orientation?"vertical" | "horizontal"verticalwheelMultiplier?numberWheel scroll speed multiplier.
1touch?booleanSmooth touch scrolling. Off by default — native momentum is good on mobile.
falseclassName?string—Scroll Progress
scroll-progress.tsxReading-progress indicator — fixed bar or circular ring — driven by scroll position via useSmoothScroll, with spring smoothing.
"use client";
import { useScroll } from "motion/react";
import { useRef } from "react";
import { ScrollProgress } from "@/components/motion/scroll-progress";
// Real usage: drop <ScrollProgress /> anywhere — it reads page scroll via
// useSmoothScroll and pins itself with `fixed`. Here we scope it to a box by
// passing a contained `progress` source and `fixed={false}`.
const SECTIONS = Array.from({ length: 18 }, (_, i) => i + 1);
export function ScrollProgressPreview() {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ container: ref });
return (
<div className="relative w-full max-w-lg overflow-hidden rounded-2xl border border-border bg-card">
<ScrollProgress progress={scrollYProgress} fixed={false} height={3} />
<div className="absolute right-3 top-3 z-10 rounded-full bg-background/70 p-1 backdrop-blur">
<ScrollProgress variant="circle" progress={scrollYProgress} size={36} />
</div>
<div ref={ref} className="h-64 overflow-y-auto scrollbar-hide">
<div className="space-y-3 p-4">
{SECTIONS.map((n) => (
<div
key={`row-${n}`}
className="rounded-lg bg-muted/60 px-3 py-4 text-sm text-muted-foreground"
>
Section {n}
</div>
))}
</div>
</div>
</div>
);
}
"use client";
// beui.dev/components/motion/scroll-animation
import {
type MotionValue,
motion,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { useSmoothScroll } from "@/components/motion/smooth-scroll";
import { cn } from "@/lib/utils";
// Soft follow so the indicator trails the scroll smoothly instead of snapping;
// looser than the UI springs in lib/ease.ts on purpose.
const PROGRESS_SPRING = { stiffness: 120, damping: 30, mass: 0.6 };
type CommonProps = {
/** Override the scroll source. Defaults to the page via useSmoothScroll. */
progress?: MotionValue<number>;
/** Spring-smooth the value. Disabled automatically under reduced motion. */
spring?: boolean;
className?: string;
};
export interface ScrollProgressBarProps extends CommonProps {
variant?: "bar";
position?: "top" | "bottom";
/** Bar thickness in px. */
height?: number;
/** Position the bar with `fixed` (page) or `absolute` (embedded). */
fixed?: boolean;
}
export interface ScrollProgressCircleProps extends CommonProps {
variant: "circle";
/** Diameter in px. */
size?: number;
/** Stroke width in px. */
thickness?: number;
}
export type ScrollProgressProps =
| ScrollProgressBarProps
| ScrollProgressCircleProps;
function useProgressValue(source: MotionValue<number> | undefined, spring: boolean) {
const reduce = useReducedMotion();
const fallback = useSmoothScroll().progress;
const raw = source ?? fallback;
const smoothed = useSpring(raw, PROGRESS_SPRING);
return spring && !reduce ? smoothed : raw;
}
export function ScrollProgress(props: ScrollProgressProps) {
if (props.variant === "circle") return <ScrollProgressCircle {...props} />;
return <ScrollProgressBar {...props} />;
}
function ScrollProgressBar({
progress,
spring = true,
position = "top",
height = 2,
fixed = true,
className,
}: ScrollProgressBarProps) {
const value = useProgressValue(progress, spring);
return (
<motion.div
aria-hidden
style={{ height, scaleX: value }}
className={cn(
"left-0 right-0 z-50 origin-left bg-foreground",
fixed ? "fixed" : "absolute",
position === "top" ? "top-0" : "bottom-0",
className,
)}
/>
);
}
function ScrollProgressCircle({
progress,
spring = true,
size = 40,
thickness = 3,
className,
}: ScrollProgressCircleProps) {
const value = useProgressValue(progress, spring);
const radius = (size - thickness) / 2;
const circumference = 2 * Math.PI * radius;
const offset = useTransform(value, (v) => circumference * (1 - v));
return (
<svg
aria-hidden="true"
focusable="false"
role="presentation"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={cn("text-foreground", className)}
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={thickness}
className="stroke-current opacity-15"
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={thickness}
strokeLinecap="round"
className="stroke-current"
strokeDasharray={circumference}
style={{ strokeDashoffset: offset }}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lenis motion tailwind-mergeAdd util file
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/scroll-animation
import {
type MotionValue,
motion,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { useSmoothScroll } from "@/components/motion/smooth-scroll";
import { cn } from "@/lib/utils";
// Soft follow so the indicator trails the scroll smoothly instead of snapping;
// looser than the UI springs in lib/ease.ts on purpose.
const PROGRESS_SPRING = { stiffness: 120, damping: 30, mass: 0.6 };
type CommonProps = {
/** Override the scroll source. Defaults to the page via useSmoothScroll. */
progress?: MotionValue<number>;
/** Spring-smooth the value. Disabled automatically under reduced motion. */
spring?: boolean;
className?: string;
};
export interface ScrollProgressBarProps extends CommonProps {
variant?: "bar";
position?: "top" | "bottom";
/** Bar thickness in px. */
height?: number;
/** Position the bar with `fixed` (page) or `absolute` (embedded). */
fixed?: boolean;
}
export interface ScrollProgressCircleProps extends CommonProps {
variant: "circle";
/** Diameter in px. */
size?: number;
/** Stroke width in px. */
thickness?: number;
}
export type ScrollProgressProps =
| ScrollProgressBarProps
| ScrollProgressCircleProps;
function useProgressValue(source: MotionValue<number> | undefined, spring: boolean) {
const reduce = useReducedMotion();
const fallback = useSmoothScroll().progress;
const raw = source ?? fallback;
const smoothed = useSpring(raw, PROGRESS_SPRING);
return spring && !reduce ? smoothed : raw;
}
export function ScrollProgress(props: ScrollProgressProps) {
if (props.variant === "circle") return <ScrollProgressCircle {...props} />;
return <ScrollProgressBar {...props} />;
}
function ScrollProgressBar({
progress,
spring = true,
position = "top",
height = 2,
fixed = true,
className,
}: ScrollProgressBarProps) {
const value = useProgressValue(progress, spring);
return (
<motion.div
aria-hidden
style={{ height, scaleX: value }}
className={cn(
"left-0 right-0 z-50 origin-left bg-foreground",
fixed ? "fixed" : "absolute",
position === "top" ? "top-0" : "bottom-0",
className,
)}
/>
);
}
function ScrollProgressCircle({
progress,
spring = true,
size = 40,
thickness = 3,
className,
}: ScrollProgressCircleProps) {
const value = useProgressValue(progress, spring);
const radius = (size - thickness) / 2;
const circumference = 2 * Math.PI * radius;
const offset = useTransform(value, (v) => circumference * (1 - v));
return (
<svg
aria-hidden="true"
focusable="false"
role="presentation"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={cn("text-foreground", className)}
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={thickness}
className="stroke-current opacity-15"
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={thickness}
strokeLinecap="round"
className="stroke-current"
strokeDasharray={circumference}
style={{ strokeDashoffset: offset }}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
);
}
"use client";
import type Lenis from "lenis";
import { ReactLenis, useLenis } from "lenis/react";
import { type MotionValue, useMotionValue, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
// Lenis' own expo-out curve — the canonical smooth-scroll easing. Kept as a
// named local fn (not a lib/ease token) because tokens are bezier control
// points for the motion lib, while Lenis needs a (t) => number easing fn.
const EASE_SCROLL = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t));
export type ScrollTarget = number | string | HTMLElement;
export type ScrollToOptions = {
offset?: number;
immediate?: boolean;
duration?: number;
};
export type SmoothScrollApi = {
/** Underlying Lenis instance, or null on the reduced-motion / native path. */
lenis: Lenis | null;
/** Current scroll offset in px. */
scrollY: MotionValue<number>;
/** Scroll position as 0..1 of the scrollable height. */
progress: MotionValue<number>;
/** Signed scroll velocity (px/frame); drives velocity-based effects. */
velocity: MotionValue<number>;
/** Programmatic smooth scroll. Respects reduced motion (jumps instantly). */
scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;
};
const SmoothScrollContext = createContext<SmoothScrollApi | null>(null);
export interface SmoothScrollProps {
children: ReactNode;
/** Drive the page (window) when true, or a contained scroll area when false. */
root?: boolean;
/** Smoothing factor; lower is smoother and heavier. */
lerp?: number;
/** Wheel / programmatic ease duration in seconds. */
duration?: number;
orientation?: "vertical" | "horizontal";
/** Wheel scroll speed multiplier. */
wheelMultiplier?: number;
/** Smooth touch scrolling. Off by default — native momentum is good on mobile. */
touch?: boolean;
className?: string;
}
type ScrollSource = Window | HTMLElement;
function readMetrics(target: ScrollSource) {
if (target instanceof Window) {
const max = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight,
);
return { y: window.scrollY, max };
}
return {
y: target.scrollTop,
max: Math.max(0, target.scrollHeight - target.clientHeight),
};
}
function resolveTop(
target: ScrollTarget,
source: ScrollSource,
offset = 0,
): number {
if (typeof target === "number") return target + offset;
if (source instanceof Window) {
const el =
typeof target === "string" ? document.querySelector(target) : target;
if (!el) return window.scrollY;
return el.getBoundingClientRect().top + window.scrollY + offset;
}
const el =
typeof target === "string" ? source.querySelector(target) : target;
if (!(el instanceof HTMLElement)) return source.scrollTop;
return el.offsetTop + offset;
}
/** Pushes Lenis' live scroll state into the shared motion values. */
function LenisBridge({
scrollY,
progress,
velocity,
lenisRef,
}: {
scrollY: MotionValue<number>;
progress: MotionValue<number>;
velocity: MotionValue<number>;
lenisRef: { current: Lenis | null };
}) {
const lenis = useLenis((instance) => {
scrollY.set(instance.scroll);
progress.set(instance.progress);
velocity.set(instance.velocity);
});
useEffect(() => {
lenisRef.current = lenis ?? null;
return () => {
lenisRef.current = null;
};
}, [lenis, lenisRef]);
return null;
}
/** Native scroll listener for the reduced-motion path and the no-provider fallback. */
function useNativeScrollSync(
enabled: boolean,
getTarget: () => ScrollSource | null,
scrollY: MotionValue<number>,
progress: MotionValue<number>,
velocity: MotionValue<number>,
) {
useEffect(() => {
if (!enabled) return;
const target = getTarget();
if (!target) return;
let lastY = readMetrics(target).y;
let lastT = performance.now();
const onScroll = () => {
const { y, max } = readMetrics(target);
const now = performance.now();
const dt = now - lastT || 16;
scrollY.set(y);
progress.set(max > 0 ? y / max : 0);
velocity.set(((y - lastY) / dt) * 16);
lastY = y;
lastT = now;
};
onScroll();
target.addEventListener("scroll", onScroll, { passive: true });
return () => target.removeEventListener("scroll", onScroll);
}, [enabled, getTarget, scrollY, progress, velocity]);
}
export function SmoothScroll({
children,
root = true,
lerp = 0.1,
duration = 1.2,
orientation = "vertical",
wheelMultiplier = 1,
touch = false,
className,
}: SmoothScrollProps) {
const reduce = useReducedMotion();
const scrollY = useMotionValue(0);
const progress = useMotionValue(0);
const velocity = useMotionValue(0);
const lenisRef = useRef<Lenis | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const nativeSource = useCallback(
(): ScrollSource | null => (root ? window : containerRef.current),
[root],
);
const scrollTo = useCallback(
(target: ScrollTarget, options?: ScrollToOptions) => {
const lenis = lenisRef.current;
if (lenis && !reduce) {
lenis.scrollTo(target, {
offset: options?.offset,
duration: options?.duration,
immediate: options?.immediate,
});
return;
}
const source = nativeSource();
const behavior = reduce || options?.immediate ? "auto" : "smooth";
const top = resolveTop(target, source ?? window, options?.offset);
(source ?? window).scrollTo({ top, behavior });
},
[reduce, nativeSource],
);
// Reduced motion drives the native listener; the Lenis path leaves it
// disabled and lets LenisBridge feed the values instead.
useNativeScrollSync(!!reduce, nativeSource, scrollY, progress, velocity);
const api = useMemo<SmoothScrollApi>(
() => ({ lenis: lenisRef.current, scrollY, progress, velocity, scrollTo }),
[scrollY, progress, velocity, scrollTo],
);
if (reduce) {
return (
<SmoothScrollContext.Provider value={api}>
<div ref={containerRef} className={className}>
{children}
</div>
</SmoothScrollContext.Provider>
);
}
return (
<SmoothScrollContext.Provider value={api}>
<ReactLenis
root={root}
className={className}
options={{
lerp,
duration,
orientation,
wheelMultiplier,
smoothWheel: true,
syncTouch: touch,
easing: EASE_SCROLL,
}}
>
<LenisBridge
scrollY={scrollY}
progress={progress}
velocity={velocity}
lenisRef={lenisRef}
/>
{children}
</ReactLenis>
</SmoothScrollContext.Provider>
);
}
/**
* Read the page's smooth-scroll state. Inside <SmoothScroll> it returns the
* shared motion values; outside it falls back to a native window scroll
* listener so scroll-driven components still work without the provider.
*/
export function useSmoothScroll(): SmoothScrollApi {
const ctx = useContext(SmoothScrollContext);
const scrollY = useMotionValue(0);
const progress = useMotionValue(0);
const velocity = useMotionValue(0);
const windowSource = useCallback((): ScrollSource => window, []);
useNativeScrollSync(ctx === null, windowSource, scrollY, progress, velocity);
const scrollTo = useCallback((target: ScrollTarget, options?: ScrollToOptions) => {
window.scrollTo({
top: resolveTop(target, window, options?.offset),
behavior: options?.immediate ? "auto" : "smooth",
});
}, []);
const fallback = useMemo<SmoothScrollApi>(
() => ({ lenis: null, scrollY, progress, velocity, scrollTo }),
[scrollY, progress, velocity, scrollTo],
);
return ctx ?? fallback;
}
API Reference
variant?"bar" | "circle"—position?"top" | "bottom"—height?numberBar thickness in px.
—fixed?booleanPosition the bar with `fixed` (page) or `absolute` (embedded).
—progress?anyOverride the scroll source. Defaults to the page via useSmoothScroll.
—spring?booleanSpring-smooth the value. Disabled automatically under reduced motion.
—className?string—size?numberDiameter in px.
—thickness?numberStroke width in px.
—Parallax
parallax.tsxWrapper that drifts its children at a speed factor as they cross the viewport, on either axis. Reduced-motion safe.
"use client";
import { useRef } from "react";
import { Parallax } from "@/components/motion/parallax";
// On a real page <Parallax> tracks the viewport. Here it's scoped to the box
// via the container prop. Scroll inside the box: the background image drifts
// against the scroll, the label and avatar drift with it at different speeds.
export function ParallaxPreview() {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className="relative h-[600px] w-full max-w-2xl overflow-y-auto scrollbar-hide rounded-2xl border border-border bg-card"
>
<div className="flex h-80 items-center justify-center text-sm text-muted-foreground">
Scroll down ↓
</div>
<div className="relative h-96 overflow-hidden">
<Parallax
container={containerRef}
speed={-0.6}
className="absolute inset-x-0 -top-1/4 h-[150%]"
>
{/* biome-ignore lint/performance/noImgElement: plain img keeps the copy-paste preview portable (no next/image host config). */}
<img
src="https://picsum.photos/seed/beui-parallax/800/600"
alt=""
className="size-full object-cover"
/>
</Parallax>
<Parallax
container={containerRef}
speed={0.5}
className="absolute inset-0 grid place-items-center"
>
<span className="rounded-full bg-background/85 px-5 py-2 text-base font-medium text-foreground backdrop-blur">
Parallax
</span>
</Parallax>
<Parallax
container={containerRef}
speed={0.9}
className="absolute bottom-4 right-4"
>
{/* biome-ignore lint/performance/noImgElement: plain img keeps the copy-paste preview portable (no next/image host config). */}
<img
src="https://picsum.photos/seed/beui-avatar/120/120"
alt=""
className="size-12 rounded-full border-2 border-background object-cover shadow-lg"
/>
</Parallax>
</div>
<div className="flex h-80 items-center justify-center text-sm text-muted-foreground">
↑ Scroll up
</div>
</div>
);
}
"use client";
// beui.dev/components/motion/scroll-animation
import {
motion,
type MotionStyle,
useReducedMotion,
useScroll,
useSpring,
useTransform,
} from "motion/react";
import { type ReactNode, type RefObject, useRef } from "react";
import { cn } from "@/lib/utils";
// Soft follow so the drift trails the scroll smoothly; looser than the UI
// springs in lib/ease.ts on purpose.
const PARALLAX_SPRING = { stiffness: 120, damping: 30, mass: 0.6 };
export interface ParallaxProps {
children: ReactNode;
/**
* Drift as a fraction of the element's travel through the viewport.
* Positive moves with the scroll (foreground), negative against it
* (background). ~0.1–0.5 reads best.
*/
speed?: number;
axis?: "x" | "y";
/** Scroll container for contained scroll areas. Defaults to the viewport. */
container?: RefObject<HTMLElement | null>;
/** Spring-smooth the drift. Disabled automatically under reduced motion. */
spring?: boolean;
className?: string;
}
export function Parallax({
children,
speed = 0.3,
axis = "y",
container,
spring = true,
className,
}: ParallaxProps) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
container,
offset: ["start end", "end start"],
// Run after paint so a container ref defined higher in the tree is hydrated;
// otherwise framer falls back to the document and only the page scroll works.
layoutEffect: false,
});
// progress 0→1 as the element crosses the viewport; map to a symmetric drift.
const travel = speed * 100;
const drift = useTransform(scrollYProgress, [0, 1], [travel, -travel]);
const smoothed = useSpring(drift, PARALLAX_SPRING);
const value = spring && !reduce ? smoothed : drift;
const style: MotionStyle = reduce ? {} : axis === "x" ? { x: value } : { y: value };
return (
<motion.div ref={ref} style={style} className={cn(className)}>
{children}
</motion.div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util file
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/scroll-animation
import {
motion,
type MotionStyle,
useReducedMotion,
useScroll,
useSpring,
useTransform,
} from "motion/react";
import { type ReactNode, type RefObject, useRef } from "react";
import { cn } from "@/lib/utils";
// Soft follow so the drift trails the scroll smoothly; looser than the UI
// springs in lib/ease.ts on purpose.
const PARALLAX_SPRING = { stiffness: 120, damping: 30, mass: 0.6 };
export interface ParallaxProps {
children: ReactNode;
/**
* Drift as a fraction of the element's travel through the viewport.
* Positive moves with the scroll (foreground), negative against it
* (background). ~0.1–0.5 reads best.
*/
speed?: number;
axis?: "x" | "y";
/** Scroll container for contained scroll areas. Defaults to the viewport. */
container?: RefObject<HTMLElement | null>;
/** Spring-smooth the drift. Disabled automatically under reduced motion. */
spring?: boolean;
className?: string;
}
export function Parallax({
children,
speed = 0.3,
axis = "y",
container,
spring = true,
className,
}: ParallaxProps) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
container,
offset: ["start end", "end start"],
// Run after paint so a container ref defined higher in the tree is hydrated;
// otherwise framer falls back to the document and only the page scroll works.
layoutEffect: false,
});
// progress 0→1 as the element crosses the viewport; map to a symmetric drift.
const travel = speed * 100;
const drift = useTransform(scrollYProgress, [0, 1], [travel, -travel]);
const smoothed = useSpring(drift, PARALLAX_SPRING);
const value = spring && !reduce ? smoothed : drift;
const style: MotionStyle = reduce ? {} : axis === "x" ? { x: value } : { y: value };
return (
<motion.div ref={ref} style={style} className={cn(className)}>
{children}
</motion.div>
);
}
API Reference
speed?numberDrift as a fraction of the element's travel through the viewport. Positive moves with the scroll (foreground), negative against it (background). ~0.1–0.5 reads best.
0.3axis?"x" | "y"ycontainer?anyScroll container for contained scroll areas. Defaults to the viewport.
—spring?booleanSpring-smooth the drift. Disabled automatically under reduced motion.
trueclassName?string—Scroll To
scroll-to.tsxButton that smooth-scrolls to a target (offset, selector or element) via the active SmoothScroll provider; reduced-motion jumps instantly.
"use client";
import { ScrollTo } from "@/components/motion/scroll-to";
import { SmoothScroll } from "@/components/motion/smooth-scroll";
// ScrollTo uses the active SmoothScroll provider. Here it's contained
// (root={false}); the nav buttons glide the box to each section.
const SECTIONS = [
{ id: "sec-intro", label: "Intro" },
{ id: "sec-features", label: "Features" },
{ id: "sec-pricing", label: "Pricing" },
{ id: "sec-faq", label: "FAQ" },
];
export function ScrollToPreview() {
return (
<SmoothScroll
root={false}
className="relative h-80 w-full max-w-lg overflow-y-auto scrollbar-hide rounded-2xl border border-border bg-card"
>
<nav className="sticky top-0 z-10 flex gap-1.5 border-b border-border bg-background/80 p-2 backdrop-blur">
{SECTIONS.map((s) => (
<ScrollTo
key={s.id}
to={`#${s.id}`}
offset={-48}
className="rounded-full px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{s.label}
</ScrollTo>
))}
</nav>
{SECTIONS.map((s) => (
<section
id={s.id}
key={s.id}
className="flex h-64 items-center justify-center text-lg font-medium text-foreground"
>
{s.label}
</section>
))}
</SmoothScroll>
);
}
"use client";
// beui.dev/components/motion/scroll-animation
import type { ButtonHTMLAttributes, ReactNode } from "react";
import {
type ScrollTarget,
type ScrollToOptions,
useSmoothScroll,
} from "@/components/motion/smooth-scroll";
import { cn } from "@/lib/utils";
export interface ScrollToProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
/** Where to scroll: px offset, selector string or element. */
to: ScrollTarget;
/** Extra px offset from the target (e.g. to clear a sticky header). */
offset?: number;
/** Override the ease duration in seconds. */
duration?: number;
children: ReactNode;
className?: string;
}
/**
* Button that smooth-scrolls to a target via the active SmoothScroll provider
* (or native scroll as a fallback). Respects reduced motion — jumps instantly.
*/
export function ScrollTo({
to,
offset,
duration,
children,
className,
...rest
}: ScrollToProps) {
const { scrollTo } = useSmoothScroll();
const options: ScrollToOptions = { offset, duration };
return (
<button
type="button"
onClick={() => scrollTo(to, options)}
className={cn(className)}
{...rest}
>
{children}
</button>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lenis motion tailwind-mergeAdd util file
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/scroll-animation
import type { ButtonHTMLAttributes, ReactNode } from "react";
import {
type ScrollTarget,
type ScrollToOptions,
useSmoothScroll,
} from "@/components/motion/smooth-scroll";
import { cn } from "@/lib/utils";
export interface ScrollToProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
/** Where to scroll: px offset, selector string or element. */
to: ScrollTarget;
/** Extra px offset from the target (e.g. to clear a sticky header). */
offset?: number;
/** Override the ease duration in seconds. */
duration?: number;
children: ReactNode;
className?: string;
}
/**
* Button that smooth-scrolls to a target via the active SmoothScroll provider
* (or native scroll as a fallback). Respects reduced motion — jumps instantly.
*/
export function ScrollTo({
to,
offset,
duration,
children,
className,
...rest
}: ScrollToProps) {
const { scrollTo } = useSmoothScroll();
const options: ScrollToOptions = { offset, duration };
return (
<button
type="button"
onClick={() => scrollTo(to, options)}
className={cn(className)}
{...rest}
>
{children}
</button>
);
}
"use client";
import type Lenis from "lenis";
import { ReactLenis, useLenis } from "lenis/react";
import { type MotionValue, useMotionValue, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
// Lenis' own expo-out curve — the canonical smooth-scroll easing. Kept as a
// named local fn (not a lib/ease token) because tokens are bezier control
// points for the motion lib, while Lenis needs a (t) => number easing fn.
const EASE_SCROLL = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t));
export type ScrollTarget = number | string | HTMLElement;
export type ScrollToOptions = {
offset?: number;
immediate?: boolean;
duration?: number;
};
export type SmoothScrollApi = {
/** Underlying Lenis instance, or null on the reduced-motion / native path. */
lenis: Lenis | null;
/** Current scroll offset in px. */
scrollY: MotionValue<number>;
/** Scroll position as 0..1 of the scrollable height. */
progress: MotionValue<number>;
/** Signed scroll velocity (px/frame); drives velocity-based effects. */
velocity: MotionValue<number>;
/** Programmatic smooth scroll. Respects reduced motion (jumps instantly). */
scrollTo: (target: ScrollTarget, options?: ScrollToOptions) => void;
};
const SmoothScrollContext = createContext<SmoothScrollApi | null>(null);
export interface SmoothScrollProps {
children: ReactNode;
/** Drive the page (window) when true, or a contained scroll area when false. */
root?: boolean;
/** Smoothing factor; lower is smoother and heavier. */
lerp?: number;
/** Wheel / programmatic ease duration in seconds. */
duration?: number;
orientation?: "vertical" | "horizontal";
/** Wheel scroll speed multiplier. */
wheelMultiplier?: number;
/** Smooth touch scrolling. Off by default — native momentum is good on mobile. */
touch?: boolean;
className?: string;
}
type ScrollSource = Window | HTMLElement;
function readMetrics(target: ScrollSource) {
if (target instanceof Window) {
const max = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight,
);
return { y: window.scrollY, max };
}
return {
y: target.scrollTop,
max: Math.max(0, target.scrollHeight - target.clientHeight),
};
}
function resolveTop(
target: ScrollTarget,
source: ScrollSource,
offset = 0,
): number {
if (typeof target === "number") return target + offset;
if (source instanceof Window) {
const el =
typeof target === "string" ? document.querySelector(target) : target;
if (!el) return window.scrollY;
return el.getBoundingClientRect().top + window.scrollY + offset;
}
const el =
typeof target === "string" ? source.querySelector(target) : target;
if (!(el instanceof HTMLElement)) return source.scrollTop;
return el.offsetTop + offset;
}
/** Pushes Lenis' live scroll state into the shared motion values. */
function LenisBridge({
scrollY,
progress,
velocity,
lenisRef,
}: {
scrollY: MotionValue<number>;
progress: MotionValue<number>;
velocity: MotionValue<number>;
lenisRef: { current: Lenis | null };
}) {
const lenis = useLenis((instance) => {
scrollY.set(instance.scroll);
progress.set(instance.progress);
velocity.set(instance.velocity);
});
useEffect(() => {
lenisRef.current = lenis ?? null;
return () => {
lenisRef.current = null;
};
}, [lenis, lenisRef]);
return null;
}
/** Native scroll listener for the reduced-motion path and the no-provider fallback. */
function useNativeScrollSync(
enabled: boolean,
getTarget: () => ScrollSource | null,
scrollY: MotionValue<number>,
progress: MotionValue<number>,
velocity: MotionValue<number>,
) {
useEffect(() => {
if (!enabled) return;
const target = getTarget();
if (!target) return;
let lastY = readMetrics(target).y;
let lastT = performance.now();
const onScroll = () => {
const { y, max } = readMetrics(target);
const now = performance.now();
const dt = now - lastT || 16;
scrollY.set(y);
progress.set(max > 0 ? y / max : 0);
velocity.set(((y - lastY) / dt) * 16);
lastY = y;
lastT = now;
};
onScroll();
target.addEventListener("scroll", onScroll, { passive: true });
return () => target.removeEventListener("scroll", onScroll);
}, [enabled, getTarget, scrollY, progress, velocity]);
}
export function SmoothScroll({
children,
root = true,
lerp = 0.1,
duration = 1.2,
orientation = "vertical",
wheelMultiplier = 1,
touch = false,
className,
}: SmoothScrollProps) {
const reduce = useReducedMotion();
const scrollY = useMotionValue(0);
const progress = useMotionValue(0);
const velocity = useMotionValue(0);
const lenisRef = useRef<Lenis | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const nativeSource = useCallback(
(): ScrollSource | null => (root ? window : containerRef.current),
[root],
);
const scrollTo = useCallback(
(target: ScrollTarget, options?: ScrollToOptions) => {
const lenis = lenisRef.current;
if (lenis && !reduce) {
lenis.scrollTo(target, {
offset: options?.offset,
duration: options?.duration,
immediate: options?.immediate,
});
return;
}
const source = nativeSource();
const behavior = reduce || options?.immediate ? "auto" : "smooth";
const top = resolveTop(target, source ?? window, options?.offset);
(source ?? window).scrollTo({ top, behavior });
},
[reduce, nativeSource],
);
// Reduced motion drives the native listener; the Lenis path leaves it
// disabled and lets LenisBridge feed the values instead.
useNativeScrollSync(!!reduce, nativeSource, scrollY, progress, velocity);
const api = useMemo<SmoothScrollApi>(
() => ({ lenis: lenisRef.current, scrollY, progress, velocity, scrollTo }),
[scrollY, progress, velocity, scrollTo],
);
if (reduce) {
return (
<SmoothScrollContext.Provider value={api}>
<div ref={containerRef} className={className}>
{children}
</div>
</SmoothScrollContext.Provider>
);
}
return (
<SmoothScrollContext.Provider value={api}>
<ReactLenis
root={root}
className={className}
options={{
lerp,
duration,
orientation,
wheelMultiplier,
smoothWheel: true,
syncTouch: touch,
easing: EASE_SCROLL,
}}
>
<LenisBridge
scrollY={scrollY}
progress={progress}
velocity={velocity}
lenisRef={lenisRef}
/>
{children}
</ReactLenis>
</SmoothScrollContext.Provider>
);
}
/**
* Read the page's smooth-scroll state. Inside <SmoothScroll> it returns the
* shared motion values; outside it falls back to a native window scroll
* listener so scroll-driven components still work without the provider.
*/
export function useSmoothScroll(): SmoothScrollApi {
const ctx = useContext(SmoothScrollContext);
const scrollY = useMotionValue(0);
const progress = useMotionValue(0);
const velocity = useMotionValue(0);
const windowSource = useCallback((): ScrollSource => window, []);
useNativeScrollSync(ctx === null, windowSource, scrollY, progress, velocity);
const scrollTo = useCallback((target: ScrollTarget, options?: ScrollToOptions) => {
window.scrollTo({
top: resolveTop(target, window, options?.offset),
behavior: options?.immediate ? "auto" : "smooth",
});
}, []);
const fallback = useMemo<SmoothScrollApi>(
() => ({ lenis: null, scrollY, progress, velocity, scrollTo }),
[scrollY, progress, velocity, scrollTo],
);
return ctx ?? fallback;
}
API Reference
toanyWhere to scroll: px offset, selector string or element.
—offset?numberExtra px offset from the target (e.g. to clear a sticky header).
—duration?numberOverride the ease duration in seconds.
—className?string—Scroll Reveal
scroll-reveal.tsxReveals its children with a spring slide and blur as they enter the viewport, once or every time. Reduced-motion keeps a fade.
"use client";
import { useRef } from "react";
import { ScrollReveal } from "@/components/motion/scroll-reveal";
// On a page <ScrollReveal> tracks the viewport. Here root points at the box so
// each card reveals as it scrolls into the contained view.
const CARDS = ["Spring slide", "Blur in", "Staggered by delay", "Reveal once"];
export function ScrollRevealPreview() {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className="h-80 w-full max-w-lg overflow-y-auto scrollbar-hide rounded-2xl border border-border bg-card"
>
<div className="flex flex-col gap-16 p-6">
<div className="text-center text-sm text-muted-foreground">
Scroll ↓
</div>
{CARDS.map((label, i) => (
<ScrollReveal
key={label}
root={containerRef}
once={false}
delay={i * 0.05}
className="rounded-xl border border-border bg-muted/50 px-4 py-16 text-center text-base font-medium text-foreground"
>
{label}
</ScrollReveal>
))}
<div className="text-center text-sm text-muted-foreground">End</div>
</div>
</div>
);
}
"use client";
// beui.dev/components/motion/scroll-animation
import { motion, useInView, useReducedMotion } from "motion/react";
import { type ReactNode, type RefObject, useRef } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ScrollRevealProps {
children: ReactNode;
/** Slide distance in px before reveal. */
y?: number;
/** Enter blur in px (kept ≤ 10 per motion conventions). */
blur?: number;
/** Reveal duration in seconds. */
duration?: number;
delay?: number;
/** Reveal only once (default) or every time it enters view. */
once?: boolean;
/** Portion of the element that must be visible to trigger. */
amount?: "some" | "all" | number;
/** Scroll root for contained scroll areas. Defaults to the viewport. */
root?: RefObject<Element | null>;
className?: string;
}
export function ScrollReveal({
children,
y = 16,
blur = 8,
duration = 0.6,
delay = 0,
once = true,
amount = 0.3,
root,
className,
}: ScrollRevealProps) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { root, once, amount });
const hidden = reduce
? { opacity: 0 }
: { opacity: 0, y, filter: `blur(${blur}px)` };
const shown = reduce
? { opacity: 1 }
: { opacity: 1, y: 0, filter: "blur(0px)" };
return (
<motion.div
ref={ref}
initial={hidden}
animate={inView ? shown : hidden}
transition={{ duration, ease: EASE_OUT, delay }}
className={cn(className)}
>
{children}
</motion.div>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/scroll-animation
import { motion, useInView, useReducedMotion } from "motion/react";
import { type ReactNode, type RefObject, useRef } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ScrollRevealProps {
children: ReactNode;
/** Slide distance in px before reveal. */
y?: number;
/** Enter blur in px (kept ≤ 10 per motion conventions). */
blur?: number;
/** Reveal duration in seconds. */
duration?: number;
delay?: number;
/** Reveal only once (default) or every time it enters view. */
once?: boolean;
/** Portion of the element that must be visible to trigger. */
amount?: "some" | "all" | number;
/** Scroll root for contained scroll areas. Defaults to the viewport. */
root?: RefObject<Element | null>;
className?: string;
}
export function ScrollReveal({
children,
y = 16,
blur = 8,
duration = 0.6,
delay = 0,
once = true,
amount = 0.3,
root,
className,
}: ScrollRevealProps) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { root, once, amount });
const hidden = reduce
? { opacity: 0 }
: { opacity: 0, y, filter: `blur(${blur}px)` };
const shown = reduce
? { opacity: 1 }
: { opacity: 1, y: 0, filter: "blur(0px)" };
return (
<motion.div
ref={ref}
initial={hidden}
animate={inView ? shown : hidden}
transition={{ duration, ease: EASE_OUT, delay }}
className={cn(className)}
>
{children}
</motion.div>
);
}
API Reference
y?numberSlide distance in px before reveal.
16blur?numberEnter blur in px (kept ≤ 10 per motion conventions).
8duration?numberReveal duration in seconds.
0.6delay?number0once?booleanReveal only once (default) or every time it enters view.
trueamount?number | "some" | "all"Portion of the element that must be visible to trigger.
0.3root?anyScroll root for contained scroll areas. Defaults to the viewport.
—className?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.
Updated