Scroll Animation
Scroll tools for long pages: SmoothScroll wraps Lenis and shares position, progress and velocity, while ScrollProgress, Parallax, ScrollTo and ScrollReveal add a reading meter, depth, anchor jumps and entrance effects. Each falls back to native behaviour under reduced motion.
Smooth Scroll
smooth-scroll.tsxWraps the page or a contained scroller in Lenis and hands out scrollY, progress and velocity motion values plus a scrollTo helper through useSmoothScroll; with reduced motion it keeps native scrolling.
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i lenis lucide-react motionCopy the source code
"use client";
// karaui.kamisama-v0.my.id/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;
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
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?"horizontal" | "vertical"verticalwheelMultiplier?numberWheel scroll speed multiplier.
1touch?booleanSmooth touch scrolling. Off by default — native momentum is good on mobile.
falseclassName?string—Scroll Progress
scroll-progress.tsxShows how far through a page the reader is, as a thin bar pinned to the top or bottom or as a circular ring, optionally spring-smoothed and fed by any progress value you pass in.
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";
// karaui.kamisama-v0.my.id/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?"circle" | "bar"—position?"bottom" | "top"—height?numberBar thickness in px.
—fixed?booleanPosition the bar with `fixed` (page) or `absolute` (embedded).
—progress?MotionValue<number>Override 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.tsxMoves its content faster or slower than the page as it passes through view, along x or y; a negative speed pushes it the other way, and it can track a custom scroll container.
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";
// karaui.kamisama-v0.my.id/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?RefObject<HTMLElement | null>Scroll 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.tsxA button that glides the page to a pixel position, CSS selector or element, with an offset for sticky headers; it uses the SmoothScroll provider when present and native scrolling otherwise.
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";
// karaui.kamisama-v0.my.id/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
toScrollTargetWhere 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.tsxFades content up into place through a short blur as it enters the viewport or a custom scroll root, once by default or every time, with distance, duration and visibility threshold props.
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";
// karaui.kamisama-v0.my.id/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 | "all" | "some"Portion of the element that must be visible to trigger.
0.3root?RefObject<Element | null>Scroll root for contained scroll areas. Defaults to the viewport.
—className?string—Updated