Scroll Animation
NewScroll-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.
TSXcomponents/previews/motion/smooth-scroll.preview.tsx
"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>
);
}
TSXcomponents/motion/smooth-scroll.tsx
"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;
}
Install
$ bunx --bun shadcn add @beui/smooth-scroll
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i lenis lucide-react motionCopy the source code
TSXcomponents/motion/smooth-scroll.tsx
"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;
}
Scroll Progress
scroll-progress.tsxReading-progress indicator — fixed bar or circular ring — driven by scroll position via useSmoothScroll, with spring smoothing.
TSXcomponents/previews/motion/scroll-progress.preview.tsx
"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>
);
}
TSXcomponents/motion/scroll-progress.tsx
"use client";
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
$ bunx --bun shadcn add @beui/scroll-progress
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lenis motion tailwind-mergeAdd util file
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/scroll-progress.tsx
"use client";
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>
);
}
TSXcomponents/motion/smooth-scroll.tsx
"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;
}
Parallax
parallax.tsxWrapper that drifts its children at a speed factor as they cross the viewport, on either axis. Reduced-motion safe.
TSXcomponents/previews/motion/parallax.preview.tsx
"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>
);
}
TSXcomponents/motion/parallax.tsx
"use client";
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
$ bunx --bun shadcn add @beui/parallax
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util file
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/parallax.tsx
"use client";
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>
);
}
Scroll To
scroll-to.tsxButton that smooth-scrolls to a target (offset, selector or element) via the active SmoothScroll provider; reduced-motion jumps instantly.
TSXcomponents/previews/motion/scroll-to.preview.tsx
"use client";
import { SmoothScroll } from "@/components/motion/smooth-scroll";
import { ScrollTo } from "@/components/motion/scroll-to";
// 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>
);
}
TSXcomponents/motion/scroll-to.tsx
"use client";
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
$ bunx --bun shadcn add @beui/scroll-to
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lenis motion tailwind-mergeAdd util file
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/scroll-to.tsx
"use client";
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>
);
}
TSXcomponents/motion/smooth-scroll.tsx
"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;
}
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.
TSXcomponents/previews/motion/scroll-reveal.preview.tsx
"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>
);
}
TSXcomponents/motion/scroll-reveal.tsx
"use client";
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
$ bunx --bun shadcn add @beui/scroll-reveal
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/scroll-reveal.tsx
"use client";
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>
);
}