Shader Background
NewCanvas shader backgrounds (mesh gradient, grain, warp, waves, voronoi, dot orbit and more) with a single typed variant prop. Reduced-motion freezes animated variants.
TSXcomponents/previews/motion/shader-background.preview.tsx
"use client";
import type { ComponentType } from "react";
import { useState } from "react";
import {
ShaderBackground,
type ShaderBackgroundProps,
type ShaderBackgroundVariant,
} from "@/components/motion/shader-background";
import { Tabs, TabsList, TabsTrigger } from "@/components/motion/tabs";
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];
return (
<div className="flex w-full max-w-2xl flex-col gap-5 p-6">
<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-background
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i @paper-design/shaders-react clsx 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;
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 { motion, MotionConfig, useReducedMotion, type Transition } from "motion/react";
import { createContext, useContext, useId, 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;
}
// Weighty spring for the active-tab indicator: a touch of overshoot so it
// settles with life instead of snapping.
const transition: Transition = {
type: "spring",
stiffness: 170,
damping: 24,
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 = (v: string) => {
if (!controlled) setInternal(v);
onValueChange?.(v);
};
return (
<MotionConfig transition={reduce ? { duration: 0 } : transition}>
<TabsCtx.Provider value={{ value: current, setValue, layoutId, variant }}>
{/* 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 }: { children: ReactNode; className?: string }) {
const { variant } = useTabs();
return (
<div role="tablist" className={cn(listClasses[variant], className)}>
{children}
</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;
const usesDefaultIndicator = indicatorClassName === undefined;
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",
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
className,
)}
>
{children}
{active ? (
<motion.span
layoutId={layoutId}
className={cn(
"absolute -bottom-px left-0 right-0 h-px bg-primary",
indicatorClassName,
)}
/>
) : null}
</button>
);
}
// The default max-contrast pill uses exclusion so labels invert exactly as
// the indicator passes beneath them. Custom indicators retain explicit text
// colors because their background may not be suitable for blending.
const radius = variant === "pill" ? "rounded-full" : "rounded-md";
return (
<div className="relative">
{active ? (
<motion.span
layoutId={layoutId}
style={{ borderRadius: variant === "pill" ? 9999 : 8 }}
className={cn(
"absolute inset-0 bg-primary",
radius,
indicatorClassName,
)}
/>
) : null}
<button
type="button"
role="tab"
aria-selected={active}
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",
usesDefaultIndicator
? "text-white mix-blend-exclusion transition-opacity"
: "transition-colors",
usesDefaultIndicator
? active
? "opacity-100"
: "opacity-70 hover:opacity-100"
: active
? "text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
radius,
className,
)}
>
{children}
</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>
);
}