Shader Background
Canvas shader backgrounds (mesh gradient, grain, warp, waves, voronoi, dot orbit and more) with a single typed variant prop. Reduced-motion freezes animated variants.
Preview
TSXcomponents/previews/motion/shader-background.preview.tsx
"use client";
import { useReducedMotion } from "motion/react";
import type { ComponentType } from "react";
import { useEffect, useState } from "react";
import {
ShaderBackground,
type ShaderBackgroundProps,
type ShaderBackgroundVariant,
} from "@/components/motion/shader-background";
import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs";
const AUTOPLAY_MS = 2400;
const VARIANTS: {
id: string;
value: ShaderBackgroundVariant;
label: string;
props: Record<string, unknown>;
}[] = [
{
id: "mesh-gradient",
value: "mesh-gradient",
label: "Mesh",
props: {
colors: ["#e0eaff", "#241d9a", "#f75092", "#9f50d3"],
distortion: 0.8,
swirl: 0.3,
speed: 0.4,
},
},
{
id: "grain-gradient",
value: "grain-gradient",
label: "Grain",
props: {
colors: ["#7300ff", "#eba8ff", "#00bfff", "#2a00ff"],
colorBack: "#000000",
softness: 0.6,
speed: 0.5,
},
},
{
id: "grain-sunset",
value: "grain-gradient",
label: "Grain — Sunset",
props: {
colors: ["#ff7a00", "#ff2e93", "#ffce54", "#8a2be2"],
colorBack: "#1a0500",
softness: 0.7,
speed: 0.4,
},
},
{
id: "grain-pastel",
value: "grain-gradient",
label: "Grain — Pastel",
props: {
colors: ["#ffd6e8", "#c9e4ff", "#fff3c4", "#d9c9ff"],
colorBack: "#ffffff",
softness: 0.85,
speed: 0.3,
},
},
{
id: "mesh-aurora",
value: "mesh-gradient",
label: "Mesh — Aurora",
props: {
colors: ["#00ffb2", "#0072ff", "#a200ff", "#001a2c"],
distortion: 0.6,
swirl: 0.5,
speed: 0.3,
},
},
{
id: "mesh-citrus",
value: "mesh-gradient",
label: "Mesh — Citrus",
props: {
colors: ["#fff200", "#ff8a00", "#ff3d00", "#ffe08a"],
distortion: 0.7,
swirl: 0.4,
speed: 0.5,
},
},
{
id: "warp",
value: "warp",
label: "Warp",
props: {
colors: ["#121212", "#9470ff", "#121212", "#8838ff"],
speed: 0.4,
},
},
{
id: "waves",
value: "waves",
label: "Waves",
props: { colorFront: "#ffbb00", colorBack: "#000000" },
},
{
id: "voronoi",
value: "voronoi",
label: "Voronoi",
props: { colors: ["#ff8247", "#ffe53d"], speed: 0.3 },
},
{
id: "swirl",
value: "swirl",
label: "Swirl",
props: {
colorBack: "#180018",
colors: ["#ffd1d1", "#ff8a8a", "#660000"],
speed: 0.2,
},
},
{
id: "dot-orbit",
value: "dot-orbit",
label: "Orbit",
props: {
colors: ["#ffc96b", "#ff6200", "#ff2f00"],
colorBack: "#000000",
speed: 0.6,
},
},
{
id: "dot-grid",
value: "dot-grid",
label: "Grid",
props: {
colorBack: "#000000",
colorFill: "#ffffff",
colorStroke: "#ffaa00",
},
},
{
id: "smoke-ring",
value: "smoke-ring",
label: "Smoke",
props: { colorBack: "#000000", colors: ["#ffffff"], speed: 0.3 },
},
{
id: "static-radial-gradient",
value: "static-radial-gradient",
label: "Radial",
props: { colorBack: "#000000", colors: ["#00bbff", "#00ffe1", "#ffffff"] },
},
{
id: "neuro-noise",
value: "neuro-noise",
label: "Neuro",
props: {
colorFront: "#ffffff",
colorMid: "#47a6ff",
colorBack: "#000000",
speed: 0.4,
},
},
{
id: "water",
value: "water",
label: "Water",
props: { colorBack: "#909090", colorHighlight: "#ffffff", speed: 0.4 },
},
{
id: "metaballs",
value: "metaballs",
label: "Metaballs",
props: {
colors: ["#ff5cf4", "#4d9eff", "#000000"],
colorBack: "#000000",
speed: 0.5,
},
},
{
id: "god-rays",
value: "god-rays",
label: "Rays",
props: {
colors: ["#ffcc66", "#ff6a00"],
colorBack: "#000000",
speed: 0.4,
},
},
{
id: "spiral",
value: "spiral",
label: "Spiral",
props: { colors: ["#7a5cff", "#ff5ca6", "#000000"], speed: 0.4 },
},
{
id: "dithering",
value: "dithering",
label: "Dither",
props: {
colorBack: "#000000",
colorFront: "#00ff9d",
speed: 0.4,
},
},
{
id: "pulsing-border",
value: "pulsing-border",
label: "Pulse",
props: {
colors: ["#00e5ff", "#7000ff", "#ff00c8"],
colorBack: "#000000",
speed: 0.5,
},
},
{
id: "color-panels",
value: "color-panels",
label: "Panels",
props: { colors: ["#ff3d68", "#ffb800", "#3d7aff", "#00ffb2"], speed: 0.3 },
},
{
id: "static-mesh-gradient",
value: "static-mesh-gradient",
label: "Static Mesh",
props: { colors: ["#ff8a3d", "#ff3d9a", "#3d5aff", "#0a0a0a"] },
},
{
id: "static-mesh-dusk",
value: "static-mesh-gradient",
label: "Static Mesh — Dusk",
props: { colors: ["#2b1055", "#7597de", "#f6a1c8", "#0d0221"] },
},
{
id: "radial-ember",
value: "static-radial-gradient",
label: "Radial — Ember",
props: { colorBack: "#0a0000", colors: ["#ff5100", "#ffce00", "#4a0000"] },
},
{
id: "simplex-noise",
value: "simplex-noise",
label: "Simplex",
props: { colors: ["#ff6ec7", "#6ec7ff", "#000000"], speed: 0.4 },
},
{
id: "perlin-noise",
value: "perlin-noise",
label: "Perlin",
props: { colorFront: "#00ffd5", colorBack: "#000000", speed: 0.3 },
},
];
// Every variant carries a different prop shape; the switcher only ever spreads
// each entry's own preset, so the loose cast here is safe.
const Background = ShaderBackground as ComponentType<
ShaderBackgroundProps & Record<string, unknown>
>;
export function ShaderBackgroundPreview() {
const [active, setActive] = useState<string>(VARIANTS[0].id);
const current = VARIANTS.find((v) => v.id === active) ?? VARIANTS[0];
const reduce = useReducedMotion();
const [paused, setPaused] = useState(false);
// Cycles through variants on its own so the preview reads as alive at a
// glance; pauses on hover/focus so picking a variant manually sticks.
useEffect(() => {
if (reduce || paused) return;
const id = setInterval(() => {
setActive((currentActive) => {
const i = VARIANTS.findIndex((v) => v.id === currentActive);
return VARIANTS[(i + 1) % VARIANTS.length].id;
});
}, AUTOPLAY_MS);
return () => clearInterval(id);
}, [reduce, paused]);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: pause-on-hover for the autoplay timer, not a real control
<div
className="flex w-full max-w-2xl flex-col gap-5 p-6"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
<div className="relative h-80 w-full overflow-hidden rounded-2xl border border-border">
<Background
key={current.id}
variant={current.value}
className="absolute inset-0"
{...current.props}
/>
</div>
<Tabs value={active} onValueChange={setActive}>
<TabsList className="flex-wrap justify-center gap-2 rounded-2xl">
{VARIANTS.map((v) => (
<TabsTrigger
key={v.id}
value={v.id}
className="px-4 py-2 text-sm"
>
{v.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
);
}
TSXcomponents/motion/shader-background.tsx
"use client";
// beui.dev/components/motion/shader-background
import {
ColorPanels,
type ColorPanelsProps,
Dithering,
type DitheringProps,
DotGrid,
type DotGridProps,
DotOrbit,
type DotOrbitProps,
GodRays,
type GodRaysProps,
GrainGradient,
type GrainGradientProps,
Metaballs,
type MetaballsProps,
MeshGradient,
type MeshGradientProps,
NeuroNoise,
type NeuroNoiseProps,
PerlinNoise,
type PerlinNoiseProps,
PulsingBorder,
type PulsingBorderProps,
SimplexNoise,
type SimplexNoiseProps,
SmokeRing,
type SmokeRingProps,
Spiral,
type SpiralProps,
StaticMeshGradient,
type StaticMeshGradientProps,
StaticRadialGradient,
type StaticRadialGradientProps,
Swirl,
type SwirlProps,
Voronoi,
type VoronoiProps,
Warp,
type WarpProps,
Water,
type WaterProps,
Waves,
type WavesProps,
} from "@paper-design/shaders-react";
import { useReducedMotion } from "motion/react";
import type { ComponentType } from "react";
import { cn } from "@/lib/utils";
type ShaderVariantProps = {
"mesh-gradient": MeshGradientProps;
"grain-gradient": GrainGradientProps;
"dot-grid": DotGridProps;
"dot-orbit": DotOrbitProps;
warp: WarpProps;
waves: WavesProps;
water: WaterProps;
voronoi: VoronoiProps;
swirl: SwirlProps;
"smoke-ring": SmokeRingProps;
"static-radial-gradient": StaticRadialGradientProps;
"neuro-noise": NeuroNoiseProps;
metaballs: MetaballsProps;
"god-rays": GodRaysProps;
spiral: SpiralProps;
dithering: DitheringProps;
"pulsing-border": PulsingBorderProps;
"color-panels": ColorPanelsProps;
"static-mesh-gradient": StaticMeshGradientProps;
"simplex-noise": SimplexNoiseProps;
"perlin-noise": PerlinNoiseProps;
};
export type ShaderBackgroundVariant = keyof ShaderVariantProps;
export type ShaderBackgroundProps = {
[K in ShaderBackgroundVariant]: { variant: K } & ShaderVariantProps[K];
}[ShaderBackgroundVariant];
const VARIANT_COMPONENTS: {
[K in ShaderBackgroundVariant]: ComponentType<ShaderVariantProps[K]>;
} = {
"mesh-gradient": MeshGradient,
"grain-gradient": GrainGradient,
"dot-grid": DotGrid,
"dot-orbit": DotOrbit,
warp: Warp,
waves: Waves,
water: Water,
voronoi: Voronoi,
swirl: Swirl,
"smoke-ring": SmokeRing,
"static-radial-gradient": StaticRadialGradient,
"neuro-noise": NeuroNoise,
metaballs: Metaballs,
"god-rays": GodRays,
spiral: Spiral,
dithering: Dithering,
"pulsing-border": PulsingBorder,
"color-panels": ColorPanels,
"static-mesh-gradient": StaticMeshGradient,
"simplex-noise": SimplexNoise,
"perlin-noise": PerlinNoise,
};
export const SHADER_BACKGROUND_VARIANTS = Object.keys(
VARIANT_COMPONENTS,
) as ShaderBackgroundVariant[];
/**
* Not every variant animates (e.g. dot-grid is a static pattern), so `speed`
* is only frozen for reduced motion when the variant actually exposes it.
*/
export function ShaderBackground({
variant,
className,
...rest
}: ShaderBackgroundProps) {
const reducedMotion = useReducedMotion();
const Shader = VARIANT_COMPONENTS[variant] as ComponentType<
Record<string, unknown>
>;
const props = rest as Record<string, unknown>;
const speedProps = reducedMotion && "speed" in props ? { speed: 0 } : {};
return (
<Shader
{...props}
{...speedProps}
className={cn("h-full w-full", className)}
/>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/shader-backgroundshader-backgroundshader-background
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i @paper-design/shaders-react clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
Copy the source code
TSXcomponents/motion/shader-background.tsx
"use client";
// beui.dev/components/motion/shader-background
import {
ColorPanels,
type ColorPanelsProps,
Dithering,
type DitheringProps,
DotGrid,
type DotGridProps,
DotOrbit,
type DotOrbitProps,
GodRays,
type GodRaysProps,
GrainGradient,
type GrainGradientProps,
Metaballs,
type MetaballsProps,
MeshGradient,
type MeshGradientProps,
NeuroNoise,
type NeuroNoiseProps,
PerlinNoise,
type PerlinNoiseProps,
PulsingBorder,
type PulsingBorderProps,
SimplexNoise,
type SimplexNoiseProps,
SmokeRing,
type SmokeRingProps,
Spiral,
type SpiralProps,
StaticMeshGradient,
type StaticMeshGradientProps,
StaticRadialGradient,
type StaticRadialGradientProps,
Swirl,
type SwirlProps,
Voronoi,
type VoronoiProps,
Warp,
type WarpProps,
Water,
type WaterProps,
Waves,
type WavesProps,
} from "@paper-design/shaders-react";
import { useReducedMotion } from "motion/react";
import type { ComponentType } from "react";
import { cn } from "@/lib/utils";
type ShaderVariantProps = {
"mesh-gradient": MeshGradientProps;
"grain-gradient": GrainGradientProps;
"dot-grid": DotGridProps;
"dot-orbit": DotOrbitProps;
warp: WarpProps;
waves: WavesProps;
water: WaterProps;
voronoi: VoronoiProps;
swirl: SwirlProps;
"smoke-ring": SmokeRingProps;
"static-radial-gradient": StaticRadialGradientProps;
"neuro-noise": NeuroNoiseProps;
metaballs: MetaballsProps;
"god-rays": GodRaysProps;
spiral: SpiralProps;
dithering: DitheringProps;
"pulsing-border": PulsingBorderProps;
"color-panels": ColorPanelsProps;
"static-mesh-gradient": StaticMeshGradientProps;
"simplex-noise": SimplexNoiseProps;
"perlin-noise": PerlinNoiseProps;
};
export type ShaderBackgroundVariant = keyof ShaderVariantProps;
export type ShaderBackgroundProps = {
[K in ShaderBackgroundVariant]: { variant: K } & ShaderVariantProps[K];
}[ShaderBackgroundVariant];
const VARIANT_COMPONENTS: {
[K in ShaderBackgroundVariant]: ComponentType<ShaderVariantProps[K]>;
} = {
"mesh-gradient": MeshGradient,
"grain-gradient": GrainGradient,
"dot-grid": DotGrid,
"dot-orbit": DotOrbit,
warp: Warp,
waves: Waves,
water: Water,
voronoi: Voronoi,
swirl: Swirl,
"smoke-ring": SmokeRing,
"static-radial-gradient": StaticRadialGradient,
"neuro-noise": NeuroNoise,
metaballs: Metaballs,
"god-rays": GodRays,
spiral: Spiral,
dithering: Dithering,
"pulsing-border": PulsingBorder,
"color-panels": ColorPanels,
"static-mesh-gradient": StaticMeshGradient,
"simplex-noise": SimplexNoise,
"perlin-noise": PerlinNoise,
};
export const SHADER_BACKGROUND_VARIANTS = Object.keys(
VARIANT_COMPONENTS,
) as ShaderBackgroundVariant[];
/**
* Not every variant animates (e.g. dot-grid is a static pattern), so `speed`
* is only frozen for reduced motion when the variant actually exposes it.
*/
export function ShaderBackground({
variant,
className,
...rest
}: ShaderBackgroundProps) {
const reducedMotion = useReducedMotion();
const Shader = VARIANT_COMPONENTS[variant] as ComponentType<
Record<string, unknown>
>;
const props = rest as Record<string, unknown>;
const speedProps = reducedMotion && "speed" in props ? { speed: 0 } : {};
return (
<Shader
{...props}
{...speedProps}
className={cn("h-full w-full", className)}
/>
);
}
TSXcomponents/motion/tabs.tsx
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cancelFrame, frame, motion, MotionConfig, useReducedMotion, type Transition } from "motion/react";
import {
createContext,
useCallback,
useContext,
useId,
useLayoutEffect,
useRef,
useMemo,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Variant = "pill" | "underline" | "segment";
type Ctx = {
value: string;
setValue: (v: string) => void;
layoutId: string;
variant: Variant;
};
const TabsCtx = createContext<Ctx | null>(null);
function useTabs() {
const ctx = useContext(TabsCtx);
if (!ctx) throw new Error("Tabs.* must be used inside <Tabs>");
return ctx;
}
// Settle without overshoot: a scrollable tab list would turn even a small
// overshoot into a transient scrollbar and layout shift.
// Scale stiffness and damping together for a quicker glide with the same feel.
const transition: Transition = {
type: "spring",
stiffness: 245,
damping: 36,
mass: 1.2,
};
export function Tabs({
defaultValue,
value,
onValueChange,
variant = "pill",
children,
className,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
variant?: Variant;
children: ReactNode;
className?: string;
}) {
const [internal, setInternal] = useState(defaultValue ?? "");
const layoutId = useId();
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
const setValue = useCallback(
(v: string) => {
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[controlled, onValueChange],
);
const contextValue = useMemo(
() => ({ value: current, setValue, layoutId, variant }),
[current, layoutId, setValue, variant],
);
return (
<MotionConfig transition={reduce ? { duration: 0 } : transition}>
<TabsCtx.Provider value={contextValue}>
{/* layoutRoot: the indicator's layoutId measures in page coordinates, so
inside fixed/scrolled containers it would replay scroll offsets as
movement. The pill only ever travels within the list, so scoping
projection to the Tabs wrapper is always correct. */}
<motion.div layoutRoot className={className}>
{children}
</motion.div>
</TabsCtx.Provider>
</MotionConfig>
);
}
const listClasses: Record<Variant, string> = {
pill: "inline-flex items-center gap-1 rounded-full bg-card p-1",
underline: "inline-flex items-center gap-1 border-b border-border",
segment: "inline-flex items-center gap-0 rounded-lg bg-card p-0.5",
};
export function TabsList({
children,
className,
wrapperClassName,
}: {
children: ReactNode;
className?: string;
wrapperClassName?: string;
}) {
const { variant, value } = useTabs();
const reduce = useReducedMotion();
const rootRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const viewportId = useId();
const [edges, setEdges] = useState({ overflow: false, left: false, right: false });
const measure = useCallback(() => {
const root = rootRef.current;
const viewport = viewportRef.current;
if (!root || !viewport) return;
// Overlay controls do not reduce the viewport or change its scroll range.
const overflow = viewport.scrollWidth > root.clientWidth + 1;
const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
const rtl = getComputedStyle(viewport).direction === "rtl";
// Modern browsers expose negative scrollLeft in RTL. Clamp rubber-banding.
const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));
const next = { overflow, left: fromLeft > 1, right: fromLeft < max - 1 };
setEdges((previous) => previous.overflow === next.overflow && previous.left === next.left && previous.right === next.right ? previous : next);
}, []);
const reveal = useCallback((tab: HTMLElement | null) => {
const viewport = viewportRef.current;
if (!viewport || !tab) return;
const frame = viewport.getBoundingClientRect();
const item = tab.getBoundingClientRect();
const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
const rtl = getComputedStyle(viewport).direction === "rtl";
const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));
// Keep the selected/focused label clear of the arrows over the faded edges.
const left = frame.left + (fromLeft > 1 ? 36 : 0);
const right = frame.right - (fromLeft < max - 1 ? 36 : 0);
const delta = item.left < left ? item.left - left : item.right > right ? item.right - right : 0;
// Scroll only this viewport; scrollIntoView can also move the whole page.
if (delta) viewport.scrollBy({ left: delta, behavior: reduce ? "instant" : "smooth" });
}, [reduce]);
useLayoutEffect(() => {
const root = rootRef.current;
const viewport = viewportRef.current;
const list = listRef.current;
if (!root || !viewport || !list) return;
const update = () => {
measure();
reveal(list.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]'));
};
const observer = new ResizeObserver(update);
observer.observe(root);
observer.observe(viewport);
observer.observe(list);
viewport.addEventListener("scroll", measure, { passive: true });
update();
return () => {
observer.disconnect();
viewport.removeEventListener("scroll", measure);
};
}, [measure, reveal]);
useLayoutEffect(() => {
// Children may change without a resize; controlled selection must also reveal.
void children;
void value;
void edges.overflow;
measure();
reveal(listRef.current?.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]') ?? null);
}, [children, value, edges.overflow, measure, reveal]);
useLayoutEffect(() => {
if (variant === "underline") return;
const list = listRef.current;
if (!list) return;
void children;
const labels = Array.from(list.querySelectorAll<HTMLElement>("[data-tabs-label]"));
const indicator = list.querySelector<HTMLElement>("[data-tabs-indicator]");
const target = list.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]');
if (!indicator || !target || target.dataset.tabsValue !== value) {
for (const label of labels) label.style.clipPath = "inset(0 100% 0 0)";
return;
}
let frames = 0;
let stillFrames = 0;
let previous: { left: number; right: number } | undefined;
const syncClips = () => {
const pill = (reduce ? target : indicator).getBoundingClientRect();
// Read ALL geometry before writing ANY masks. A loop per tab interleaved
// reads and writes, forcing the browser to flush styles repeatedly.
const clips = labels.map((label) => {
const bounds = label.getBoundingClientRect();
const left = Math.max(0, Math.min(bounds.width, pill.left - bounds.left));
const right = Math.max(0, Math.min(bounds.width, bounds.right - pill.right));
return left + right >= bounds.width ? "inset(0 100% 0 0)" : `inset(0 ${right}px 0 ${left}px)`;
});
labels.forEach((label, index) => {
if (label.style.clipPath !== clips[index]) label.style.clipPath = clips[index];
});
frames += 1;
stillFrames = previous && Math.abs(pill.left - previous.left) < 0.01 && Math.abs(pill.right - previous.right) < 0.01 ? stillFrames + 1 : 0;
previous = { left: pill.left, right: pill.right };
if (reduce || (frames > 2 && stillFrames >= 2)) cancelFrame(syncClips);
};
// One shared pass after Motion paints the projected pill keeps every label
// in sync, including labels crossed during a long or interrupted glide.
frame.postRender(syncClips, true);
return () => cancelFrame(syncClips);
}, [value, children, variant, reduce]);
const scroll = (direction: number) => {
const viewport = viewportRef.current;
if (viewport) viewport.scrollBy({ left: direction * viewport.clientWidth * 0.8, behavior: reduce ? "instant" : "smooth" });
};
const controlClass = "absolute inset-y-0 z-20 inline-flex w-9 items-center justify-center text-foreground transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-0";
const surfaceClass = variant === "pill" ? "rounded-full bg-card" : variant === "segment" ? "rounded-lg bg-card" : "";
return (
<div ref={rootRef} className={cn("relative isolate flex w-full max-w-full min-w-0 items-center", edges.overflow && surfaceClass, wrapperClassName)}>
{edges.overflow && (
<button type="button" aria-label="Scroll tabs left" aria-controls={viewportId} disabled={!edges.left} onClick={() => scroll(-1)} className={cn(controlClass, "left-0 rounded-l-full")}>
<ChevronLeft size={20} aria-hidden="true" />
</button>
)}
<motion.div
ref={viewportRef}
id={viewportId}
layoutScroll
className={cn("w-full min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", edges.overflow && "[border-radius:inherit]")}
style={edges.overflow ? {
maskImage: `linear-gradient(to right, ${edges.left ? "transparent, black 40px" : "black, black 0px"}, ${edges.right ? "black calc(100% - 40px), transparent" : "black 100%"})`,
} : undefined}
onFocusCapture={(event) => {
if (event.target instanceof HTMLElement && event.target.getAttribute("role") === "tab") reveal(event.target);
}}
>
<div ref={listRef} role="tablist" className={cn(listClasses[variant], "w-max", className)}>
{children}
</div>
</motion.div>
{edges.overflow && edges.left && (
<span aria-hidden="true" className="pointer-events-none absolute inset-y-0 left-0 z-10 w-10 rounded-l-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_right,black,transparent)]" />
)}
{edges.overflow && edges.right && (
<span aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 z-10 w-10 rounded-r-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_left,black,transparent)]" />
)}
{edges.overflow && (
<button type="button" aria-label="Scroll tabs right" aria-controls={viewportId} disabled={!edges.right} onClick={() => scroll(1)} className={cn(controlClass, "right-0 rounded-r-full")}>
<ChevronRight size={20} aria-hidden="true" />
</button>
)}
</div>
);
}
export function TabsTrigger({
value,
children,
className,
indicatorClassName,
}: {
value: string;
children: ReactNode;
className?: string;
indicatorClassName?: string;
}) {
const { value: current, setValue, layoutId, variant } = useTabs();
const active = current === value;
// React owns the initial mask only; TabsList synchronizes subsequent masks.
const [initialClip] = useState(() => active ? "inset(0)" : "inset(0 100% 0 0)");
if (variant === "underline") {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => setValue(value)}
className={cn(
"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center whitespace-nowrap shrink-0",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
className,
)}
>
{children}
{active ? (
<motion.span
layoutId={layoutId}
layout
className={cn(
"absolute bottom-0 left-0 right-0 h-px bg-primary",
indicatorClassName,
)}
/>
) : null}
</button>
);
}
const radius = variant === "pill" ? "rounded-full" : "rounded-md";
return (
<div className="relative shrink-0">
{active ? (
<motion.span
data-tabs-indicator=""
layoutId={layoutId}
layout
style={{ borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn(
"absolute inset-0 bg-primary",
radius,
indicatorClassName,
)}
/>
) : null}
<button
type="button"
role="tab"
aria-selected={active}
data-tabs-value={value}
onClick={() => setValue(value)}
className={cn(
"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none",
"text-muted-foreground hover:text-foreground",
radius,
className,
)}
>
{children}
<span
data-tabs-label=""
aria-hidden="true"
inert
className="pointer-events-none absolute inset-0 inline-flex items-center justify-center text-primary-foreground [gap:inherit] [padding:inherit]"
style={{ clipPath: initialClip }}
>
{children}
</span>
</button>
</div>
);
}
export function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {
const { value: current } = useTabs();
const reduce = useReducedMotion();
const active = current === value;
// Inactive panels stay mounted but hidden, so their content (e.g. source
// code) is present in the server-rendered HTML for crawlers and assistive
// tech, instead of being dropped from the DOM.
if (!active) {
return (
<div hidden className={className}>
{children}
</div>
);
}
return (
<motion.div
key={value}
initial={{ opacity: 0, y: reduce ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: EASE_OUT }}
className={cn("mt-4", className)}
>
{children}
</motion.div>
);
}
API Reference
className?string—Related components
Cylinder Carousel
A carousel whose items line the inside of a cylinder, receding into the center and growing toward the edges. Drag, scroll or arrow-key to roll it, with a springy glide and snap. Reduced-motion drops the glide.
Tilt Card
3D perspective tilt on hover with cursor-tracked glare.
Marquee
Infinite horizontal or vertical scroll with pause-on-hover.
Updated