404 / Not Found
Animated 404 pages in five styles: glitch scramble, magnetic digits, cursor spotlight, a fanning card stack and a typed terminal.
Glitch
glitch.tsxDigits scramble through random glyphs before resolving, with a chromatic split on hover.
404
Page not found
The page you are looking for moved, vanished, or never existed.
"use client";
import { NotFoundGlitch } from "@/components/motion/not-found/glitch";
export function NotFoundGlitchPreview() {
return (
<div className="w-full">
<NotFoundGlitch />
</div>
);
}
"use client";
// beui.dev/components/blocks/not-found
import { useEffect, useState } from "react";
import { useReducedMotion } from "motion/react";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
const GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/\\";
const SCRAMBLE_MS = 700;
const TICK_MS = 45;
/**
* Renders the code, scrambling each character on mount before it settles.
* SSR and the first paint show the real code, so the scramble is a pure
* client-side enhancement and reduced-motion users see the code immediately.
*/
function Scramble({ text }: { text: string }) {
const reduce = useReducedMotion();
const [display, setDisplay] = useState(text);
useEffect(() => {
if (reduce) {
setDisplay(text);
return;
}
const chars = text.split("");
const start = performance.now();
let raf = 0;
let last = 0;
const loop = (now: number) => {
if (now - last >= TICK_MS) {
last = now;
const progress = Math.min((now - start) / SCRAMBLE_MS, 1);
const settled = Math.floor(progress * chars.length);
setDisplay(
chars
.map((ch, i) =>
i < settled || ch === " "
? ch
: GLYPHS[Math.floor(Math.random() * GLYPHS.length)],
)
.join(""),
);
}
if (now - start < SCRAMBLE_MS) {
raf = requestAnimationFrame(loop);
} else {
setDisplay(text);
}
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [text, reduce]);
return <span className="tabular-nums">{display}</span>;
}
export function NotFoundGlitch({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
return (
<NotFoundStage className={className}>
<div className="group relative select-none font-mono font-bold leading-none tracking-tighter text-foreground [font-size:clamp(5rem,18vw,11rem)]">
{/* Chromatic ghost layers, nudged apart on hover. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 text-[#ff0040] opacity-0 mix-blend-screen transition-[transform,opacity] duration-150 ease-out group-hover:translate-x-[3px] group-hover:opacity-70 motion-reduce:hidden"
>
<Scramble text={code} />
</span>
<span
aria-hidden
className="pointer-events-none absolute inset-0 text-[#00e5ff] opacity-0 mix-blend-screen transition-[transform,opacity] duration-150 ease-out group-hover:-translate-x-[3px] group-hover:opacity-70 motion-reduce:hidden"
>
<Scramble text={code} />
</span>
<h1 className="relative">
<Scramble text={code} />
</h1>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
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;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/blocks/not-found
import { useEffect, useState } from "react";
import { useReducedMotion } from "motion/react";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
const GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&@$?/\\";
const SCRAMBLE_MS = 700;
const TICK_MS = 45;
/**
* Renders the code, scrambling each character on mount before it settles.
* SSR and the first paint show the real code, so the scramble is a pure
* client-side enhancement and reduced-motion users see the code immediately.
*/
function Scramble({ text }: { text: string }) {
const reduce = useReducedMotion();
const [display, setDisplay] = useState(text);
useEffect(() => {
if (reduce) {
setDisplay(text);
return;
}
const chars = text.split("");
const start = performance.now();
let raf = 0;
let last = 0;
const loop = (now: number) => {
if (now - last >= TICK_MS) {
last = now;
const progress = Math.min((now - start) / SCRAMBLE_MS, 1);
const settled = Math.floor(progress * chars.length);
setDisplay(
chars
.map((ch, i) =>
i < settled || ch === " "
? ch
: GLYPHS[Math.floor(Math.random() * GLYPHS.length)],
)
.join(""),
);
}
if (now - start < SCRAMBLE_MS) {
raf = requestAnimationFrame(loop);
} else {
setDisplay(text);
}
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [text, reduce]);
return <span className="tabular-nums">{display}</span>;
}
export function NotFoundGlitch({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
return (
<NotFoundStage className={className}>
<div className="group relative select-none font-mono font-bold leading-none tracking-tighter text-foreground [font-size:clamp(5rem,18vw,11rem)]">
{/* Chromatic ghost layers, nudged apart on hover. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 text-[#ff0040] opacity-0 mix-blend-screen transition-[transform,opacity] duration-150 ease-out group-hover:translate-x-[3px] group-hover:opacity-70 motion-reduce:hidden"
>
<Scramble text={code} />
</span>
<span
aria-hidden
className="pointer-events-none absolute inset-0 text-[#00e5ff] opacity-0 mix-blend-screen transition-[transform,opacity] duration-150 ease-out group-hover:-translate-x-[3px] group-hover:opacity-70 motion-reduce:hidden"
>
<Scramble text={code} />
</span>
<h1 className="relative">
<Scramble text={code} />
</h1>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface NotFoundProps {
className?: string;
/** The big status code. */
code?: string;
title?: string;
description?: string;
homeHref?: string;
homeLabel?: string;
browseHref?: string;
browseLabel?: string;
}
export const NOT_FOUND_DEFAULTS = {
code: "404",
title: "Page not found",
description:
"The page you are looking for moved, vanished, or never existed.",
homeHref: "/",
homeLabel: "Back home",
browseHref: "/components/motion",
browseLabel: "Browse components",
} as const;
type ActionsProps = Pick<
NotFoundProps,
"homeHref" | "homeLabel" | "browseHref" | "browseLabel" | "className"
>;
/** The shared dual CTA: a primary "Back home" and a secondary "Browse". */
export function NotFoundActions({
homeHref = NOT_FOUND_DEFAULTS.homeHref,
homeLabel = NOT_FOUND_DEFAULTS.homeLabel,
browseHref = NOT_FOUND_DEFAULTS.browseHref,
browseLabel = NOT_FOUND_DEFAULTS.browseLabel,
className,
}: ActionsProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const whileTap = reduce ? undefined : { scale: 0.96 };
const whileHover = reduce || !canHover ? undefined : { scale: 1.02 };
return (
<div
className={cn(
"flex flex-wrap items-center justify-center gap-3",
className,
)}
>
<motion.a
href={homeHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full bg-primary px-6 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{homeLabel}
</motion.a>
<motion.a
href={browseHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full border border-border bg-card px-6 text-sm font-medium text-foreground transition-colors hover:bg-primary/5"
>
{browseLabel}
</motion.a>
</div>
);
}
/** Centers a variant and gives it a consistent minimum stage height. */
export function NotFoundStage({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"flex min-h-[420px] w-full flex-col items-center justify-center gap-8 px-4 text-center",
className,
)}
>
{children}
</div>
);
}
API Reference
className?string—code?stringThe big status code.
404title?stringPage not founddescription?stringThe page you are looking for moved, vanished, or never existed.homeHref?string—homeLabel?string—browseHref?string—browseLabel?string—Magnetic
magnetic.tsxEach digit is cursor-attracted via the Magnetic wrapper and springs back on leave.
404
Page not found
The page you are looking for moved, vanished, or never existed.
"use client";
import { NotFoundMagnetic } from "@/components/motion/not-found/magnetic";
export function NotFoundMagneticPreview() {
return (
<div className="w-full">
<NotFoundMagnetic />
</div>
);
}
"use client";
// beui.dev/components/blocks/not-found
import { Magnetic } from "@/components/motion/magnetic";
import { cn } from "@/lib/utils";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
export function NotFoundMagnetic({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
const chars = code.split("");
return (
<NotFoundStage className={className}>
<h1
aria-label={code}
className="flex select-none items-center justify-center font-bold leading-none tracking-tighter text-foreground [font-size:clamp(5rem,18vw,12rem)]"
>
{chars.map((ch, i) => (
<Magnetic
// biome-ignore lint/suspicious/noArrayIndexKey: fixed positional glyphs
key={i}
strength={0.6}
className={cn(i > 0 && "-ml-2")}
>
<span aria-hidden className="inline-block px-1 tabular-nums">
{ch}
</span>
</Magnetic>
))}
</h1>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// beui.dev/components/blocks/not-found
import { Magnetic } from "@/components/motion/magnetic";
import { cn } from "@/lib/utils";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
export function NotFoundMagnetic({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
const chars = code.split("");
return (
<NotFoundStage className={className}>
<h1
aria-label={code}
className="flex select-none items-center justify-center font-bold leading-none tracking-tighter text-foreground [font-size:clamp(5rem,18vw,12rem)]"
>
{chars.map((ch, i) => (
<Magnetic
// biome-ignore lint/suspicious/noArrayIndexKey: fixed positional glyphs
key={i}
strength={0.6}
className={cn(i > 0 && "-ml-2")}
>
<span aria-hidden className="inline-block px-1 tabular-nums">
{ch}
</span>
</Magnetic>
))}
</h1>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface NotFoundProps {
className?: string;
/** The big status code. */
code?: string;
title?: string;
description?: string;
homeHref?: string;
homeLabel?: string;
browseHref?: string;
browseLabel?: string;
}
export const NOT_FOUND_DEFAULTS = {
code: "404",
title: "Page not found",
description:
"The page you are looking for moved, vanished, or never existed.",
homeHref: "/",
homeLabel: "Back home",
browseHref: "/components/motion",
browseLabel: "Browse components",
} as const;
type ActionsProps = Pick<
NotFoundProps,
"homeHref" | "homeLabel" | "browseHref" | "browseLabel" | "className"
>;
/** The shared dual CTA: a primary "Back home" and a secondary "Browse". */
export function NotFoundActions({
homeHref = NOT_FOUND_DEFAULTS.homeHref,
homeLabel = NOT_FOUND_DEFAULTS.homeLabel,
browseHref = NOT_FOUND_DEFAULTS.browseHref,
browseLabel = NOT_FOUND_DEFAULTS.browseLabel,
className,
}: ActionsProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const whileTap = reduce ? undefined : { scale: 0.96 };
const whileHover = reduce || !canHover ? undefined : { scale: 1.02 };
return (
<div
className={cn(
"flex flex-wrap items-center justify-center gap-3",
className,
)}
>
<motion.a
href={homeHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full bg-primary px-6 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{homeLabel}
</motion.a>
<motion.a
href={browseHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full border border-border bg-card px-6 text-sm font-medium text-foreground transition-colors hover:bg-primary/5"
>
{browseLabel}
</motion.a>
</div>
);
}
/** Centers a variant and gives it a consistent minimum stage height. */
export function NotFoundStage({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"flex min-h-[420px] w-full flex-col items-center justify-center gap-8 px-4 text-center",
className,
)}
>
{children}
</div>
);
}
"use client";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { useRef, type ReactNode } from "react";
import { SPRING_MOUSE } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface MagneticProps {
children: ReactNode;
strength?: number;
className?: string;
}
export function Magnetic({ children, strength = 0.35, className }: MagneticProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
// Decorative cursor-follow: skip on touch (phantom hover) and reduced motion.
const enabled = !reduce && canHover;
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING_MOUSE);
const sy = useSpring(y, SPRING_MOUSE);
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
x.set((e.clientX - rect.left - rect.width / 2) * strength);
y.set((e.clientY - rect.top - rect.height / 2) * strength);
};
const onLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
onMouseMove={onMove}
onMouseLeave={onLeave}
style={{ x: sx, y: sy }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
);
}
API Reference
className?string—code?stringThe big status code.
404title?stringPage not founddescription?stringThe page you are looking for moved, vanished, or never existed.homeHref?string—homeLabel?string—browseHref?string—browseLabel?string—Spotlight
spotlight.tsxA dark panel where a cursor-tracked spotlight reveals the bright code from a dim base.
404
Page not found
The page you are looking for moved, vanished, or never existed.
"use client";
import { NotFoundSpotlight } from "@/components/motion/not-found/spotlight";
export function NotFoundSpotlightPreview() {
return (
<div className="w-full">
<NotFoundSpotlight />
</div>
);
}
"use client";
// beui.dev/components/blocks/not-found
import { useRef } from "react";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
} from "motion/react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
export function NotFoundSpotlight({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const enabled = !reduce && canHover;
const mx = useMotionValue(50);
const my = useMotionValue(50);
const mask = useMotionTemplate`radial-gradient(220px circle at ${mx}% ${my}%, #000 25%, transparent 72%)`;
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
mx.set(((e.clientX - rect.left) / rect.width) * 100);
my.set(((e.clientY - rect.top) / rect.height) * 100);
};
return (
<NotFoundStage className={className}>
<motion.div
ref={ref}
onMouseMove={onMove}
className="relative isolate flex aspect-[16/9] w-full max-w-xl items-center justify-center overflow-hidden rounded-3xl border border-border bg-neutral-950"
>
{/* Dim base layer. */}
<span
aria-hidden
className="select-none font-bold leading-none tracking-tighter text-white/10 [font-size:clamp(5rem,16vw,10rem)]"
>
{code}
</span>
{/* Bright layer, revealed only under the spotlight. */}
<motion.h1
aria-label={code}
style={enabled ? { WebkitMaskImage: mask, maskImage: mask } : undefined}
className={cn(
"absolute select-none font-bold leading-none tracking-tighter text-white [font-size:clamp(5rem,16vw,10rem)]",
!enabled && "text-white/90",
)}
>
<span aria-hidden>{code}</span>
</motion.h1>
</motion.div>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
Install
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 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
"use client";
// beui.dev/components/blocks/not-found
import { useRef } from "react";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
} from "motion/react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
export function NotFoundSpotlight({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
const ref = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const enabled = !reduce && canHover;
const mx = useMotionValue(50);
const my = useMotionValue(50);
const mask = useMotionTemplate`radial-gradient(220px circle at ${mx}% ${my}%, #000 25%, transparent 72%)`;
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || !enabled) return;
const rect = el.getBoundingClientRect();
mx.set(((e.clientX - rect.left) / rect.width) * 100);
my.set(((e.clientY - rect.top) / rect.height) * 100);
};
return (
<NotFoundStage className={className}>
<motion.div
ref={ref}
onMouseMove={onMove}
className="relative isolate flex aspect-[16/9] w-full max-w-xl items-center justify-center overflow-hidden rounded-3xl border border-border bg-neutral-950"
>
{/* Dim base layer. */}
<span
aria-hidden
className="select-none font-bold leading-none tracking-tighter text-white/10 [font-size:clamp(5rem,16vw,10rem)]"
>
{code}
</span>
{/* Bright layer, revealed only under the spotlight. */}
<motion.h1
aria-label={code}
style={enabled ? { WebkitMaskImage: mask, maskImage: mask } : undefined}
className={cn(
"absolute select-none font-bold leading-none tracking-tighter text-white [font-size:clamp(5rem,16vw,10rem)]",
!enabled && "text-white/90",
)}
>
<span aria-hidden>{code}</span>
</motion.h1>
</motion.div>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface NotFoundProps {
className?: string;
/** The big status code. */
code?: string;
title?: string;
description?: string;
homeHref?: string;
homeLabel?: string;
browseHref?: string;
browseLabel?: string;
}
export const NOT_FOUND_DEFAULTS = {
code: "404",
title: "Page not found",
description:
"The page you are looking for moved, vanished, or never existed.",
homeHref: "/",
homeLabel: "Back home",
browseHref: "/components/motion",
browseLabel: "Browse components",
} as const;
type ActionsProps = Pick<
NotFoundProps,
"homeHref" | "homeLabel" | "browseHref" | "browseLabel" | "className"
>;
/** The shared dual CTA: a primary "Back home" and a secondary "Browse". */
export function NotFoundActions({
homeHref = NOT_FOUND_DEFAULTS.homeHref,
homeLabel = NOT_FOUND_DEFAULTS.homeLabel,
browseHref = NOT_FOUND_DEFAULTS.browseHref,
browseLabel = NOT_FOUND_DEFAULTS.browseLabel,
className,
}: ActionsProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const whileTap = reduce ? undefined : { scale: 0.96 };
const whileHover = reduce || !canHover ? undefined : { scale: 1.02 };
return (
<div
className={cn(
"flex flex-wrap items-center justify-center gap-3",
className,
)}
>
<motion.a
href={homeHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full bg-primary px-6 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{homeLabel}
</motion.a>
<motion.a
href={browseHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full border border-border bg-card px-6 text-sm font-medium text-foreground transition-colors hover:bg-primary/5"
>
{browseLabel}
</motion.a>
</div>
);
}
/** Centers a variant and gives it a consistent minimum stage height. */
export function NotFoundStage({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"flex min-h-[420px] w-full flex-col items-center justify-center gap-8 px-4 text-center",
className,
)}
>
{children}
</div>
);
}
API Reference
className?string—code?stringThe big status code.
404title?stringPage not founddescription?stringThe page you are looking for moved, vanished, or never existed.homeHref?string—homeLabel?string—browseHref?string—browseLabel?string—Stacked
stacked.tsxA code card over a hidden stack that fans out with a spring on hover.
404
out of the deckPage not found
The page you are looking for moved, vanished, or never existed.
"use client";
import { NotFoundStacked } from "@/components/motion/not-found/stacked";
export function NotFoundStackedPreview() {
return (
<div className="w-full">
<NotFoundStacked />
</div>
);
}
"use client";
// beui.dev/components/blocks/not-found
import { motion, useReducedMotion } from "motion/react";
import { SPRING_PANEL } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
const CARD =
"absolute inset-0 rounded-3xl border border-border bg-card shadow-sm";
export function NotFoundStacked({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const interactive = !reduce && canHover;
return (
<NotFoundStage className={className}>
<motion.div
initial="rest"
animate="rest"
whileHover={interactive ? "hover" : undefined}
className="relative h-44 w-64"
>
<motion.div
aria-hidden
variants={{ rest: { rotate: 0, x: 0, y: 0 }, hover: { rotate: -9, x: -28, y: 8 } }}
transition={SPRING_PANEL}
className={CARD}
/>
<motion.div
aria-hidden
variants={{ rest: { rotate: 0, x: 0, y: 0 }, hover: { rotate: 9, x: 28, y: 8 } }}
transition={SPRING_PANEL}
className={CARD}
/>
<motion.div
variants={{ rest: { y: 0 }, hover: { y: -6 } }}
transition={SPRING_PANEL}
className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-3xl border border-border bg-card shadow-md"
>
<h1 className="select-none font-bold leading-none tracking-tighter text-foreground [font-size:clamp(3.5rem,9vw,5rem)]">
{code}
</h1>
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
out of the deck
</span>
</motion.div>
</motion.div>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
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;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/blocks/not-found
import { motion, useReducedMotion } from "motion/react";
import { SPRING_PANEL } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
const CARD =
"absolute inset-0 rounded-3xl border border-border bg-card shadow-sm";
export function NotFoundStacked({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const interactive = !reduce && canHover;
return (
<NotFoundStage className={className}>
<motion.div
initial="rest"
animate="rest"
whileHover={interactive ? "hover" : undefined}
className="relative h-44 w-64"
>
<motion.div
aria-hidden
variants={{ rest: { rotate: 0, x: 0, y: 0 }, hover: { rotate: -9, x: -28, y: 8 } }}
transition={SPRING_PANEL}
className={CARD}
/>
<motion.div
aria-hidden
variants={{ rest: { rotate: 0, x: 0, y: 0 }, hover: { rotate: 9, x: 28, y: 8 } }}
transition={SPRING_PANEL}
className={CARD}
/>
<motion.div
variants={{ rest: { y: 0 }, hover: { y: -6 } }}
transition={SPRING_PANEL}
className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-3xl border border-border bg-card shadow-md"
>
<h1 className="select-none font-bold leading-none tracking-tighter text-foreground [font-size:clamp(3.5rem,9vw,5rem)]">
{code}
</h1>
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
out of the deck
</span>
</motion.div>
</motion.div>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface NotFoundProps {
className?: string;
/** The big status code. */
code?: string;
title?: string;
description?: string;
homeHref?: string;
homeLabel?: string;
browseHref?: string;
browseLabel?: string;
}
export const NOT_FOUND_DEFAULTS = {
code: "404",
title: "Page not found",
description:
"The page you are looking for moved, vanished, or never existed.",
homeHref: "/",
homeLabel: "Back home",
browseHref: "/components/motion",
browseLabel: "Browse components",
} as const;
type ActionsProps = Pick<
NotFoundProps,
"homeHref" | "homeLabel" | "browseHref" | "browseLabel" | "className"
>;
/** The shared dual CTA: a primary "Back home" and a secondary "Browse". */
export function NotFoundActions({
homeHref = NOT_FOUND_DEFAULTS.homeHref,
homeLabel = NOT_FOUND_DEFAULTS.homeLabel,
browseHref = NOT_FOUND_DEFAULTS.browseHref,
browseLabel = NOT_FOUND_DEFAULTS.browseLabel,
className,
}: ActionsProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const whileTap = reduce ? undefined : { scale: 0.96 };
const whileHover = reduce || !canHover ? undefined : { scale: 1.02 };
return (
<div
className={cn(
"flex flex-wrap items-center justify-center gap-3",
className,
)}
>
<motion.a
href={homeHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full bg-primary px-6 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{homeLabel}
</motion.a>
<motion.a
href={browseHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full border border-border bg-card px-6 text-sm font-medium text-foreground transition-colors hover:bg-primary/5"
>
{browseLabel}
</motion.a>
</div>
);
}
/** Centers a variant and gives it a consistent minimum stage height. */
export function NotFoundStage({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"flex min-h-[420px] w-full flex-col items-center justify-center gap-8 px-4 text-center",
className,
)}
>
{children}
</div>
);
}
API Reference
className?string—code?stringThe big status code.
404title?stringPage not founddescription?stringThe page you are looking for moved, vanished, or never existed.homeHref?string—homeLabel?string—browseHref?string—browseLabel?string—Terminal
terminal.tsxA terminal window that types a failed cd command and a 404 status, with a blinking caret.
$ cd /page
cd: no such file or directory: /page
$ status 404
Page not found
The page you are looking for moved, vanished, or never existed.
"use client";
import { NotFoundTerminal } from "@/components/motion/not-found/terminal";
export function NotFoundTerminalPreview() {
return (
<div className="w-full">
<NotFoundTerminal />
</div>
);
}
"use client";
// beui.dev/components/blocks/not-found
import { TextReveal } from "@/components/motion/text-reveal";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
const TYPE_SPRING = { stiffness: 320, damping: 30, mass: 0.6 };
export function NotFoundTerminal({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
return (
<NotFoundStage className={className}>
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-neutral-950 text-left shadow-lg">
<div className="flex items-center gap-1.5 border-b border-white/10 px-4 py-3">
<span className="h-3 w-3 rounded-full bg-[#ff5f57]" />
<span className="h-3 w-3 rounded-full bg-[#febc2e]" />
<span className="h-3 w-3 rounded-full bg-[#28c840]" />
<span className="ml-2 text-xs text-white/40">~/beui</span>
</div>
<div className="space-y-1.5 p-4 font-mono text-sm leading-relaxed">
<TextReveal
as="p"
split="char"
stagger={0.018}
blur={6}
yOffset={0}
spring={TYPE_SPRING}
className="text-white/80"
text="$ cd /page"
/>
<TextReveal
as="p"
split="char"
stagger={0.012}
delay={0.45}
blur={6}
yOffset={0}
spring={TYPE_SPRING}
className="text-[#ff5f57]"
text="cd: no such file or directory: /page"
/>
<p className="flex items-center text-white/80">
<TextReveal
as="span"
split="char"
stagger={0.018}
delay={1.1}
blur={6}
yOffset={0}
spring={TYPE_SPRING}
text={`$ status ${code}`}
/>
<span className="ml-1 inline-block h-[1.1em] w-[0.55ch] translate-y-[0.12em] bg-white/80 motion-safe:animate-pulse" />
</p>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
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;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/blocks/not-found
import { TextReveal } from "@/components/motion/text-reveal";
import {
NOT_FOUND_DEFAULTS,
NotFoundActions,
NotFoundStage,
type NotFoundProps,
} from "./shared";
const TYPE_SPRING = { stiffness: 320, damping: 30, mass: 0.6 };
export function NotFoundTerminal({
className,
code = NOT_FOUND_DEFAULTS.code,
title = NOT_FOUND_DEFAULTS.title,
description = NOT_FOUND_DEFAULTS.description,
homeHref,
homeLabel,
browseHref,
browseLabel,
}: NotFoundProps) {
return (
<NotFoundStage className={className}>
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-neutral-950 text-left shadow-lg">
<div className="flex items-center gap-1.5 border-b border-white/10 px-4 py-3">
<span className="h-3 w-3 rounded-full bg-[#ff5f57]" />
<span className="h-3 w-3 rounded-full bg-[#febc2e]" />
<span className="h-3 w-3 rounded-full bg-[#28c840]" />
<span className="ml-2 text-xs text-white/40">~/beui</span>
</div>
<div className="space-y-1.5 p-4 font-mono text-sm leading-relaxed">
<TextReveal
as="p"
split="char"
stagger={0.018}
blur={6}
yOffset={0}
spring={TYPE_SPRING}
className="text-white/80"
text="$ cd /page"
/>
<TextReveal
as="p"
split="char"
stagger={0.012}
delay={0.45}
blur={6}
yOffset={0}
spring={TYPE_SPRING}
className="text-[#ff5f57]"
text="cd: no such file or directory: /page"
/>
<p className="flex items-center text-white/80">
<TextReveal
as="span"
split="char"
stagger={0.018}
delay={1.1}
blur={6}
yOffset={0}
spring={TYPE_SPRING}
text={`$ status ${code}`}
/>
<span className="ml-1 inline-block h-[1.1em] w-[0.55ch] translate-y-[0.12em] bg-white/80 motion-safe:animate-pulse" />
</p>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-lg font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
<NotFoundActions
homeHref={homeHref}
homeLabel={homeLabel}
browseHref={browseHref}
browseLabel={browseLabel}
/>
</NotFoundStage>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import { SPRING_PRESS } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export interface NotFoundProps {
className?: string;
/** The big status code. */
code?: string;
title?: string;
description?: string;
homeHref?: string;
homeLabel?: string;
browseHref?: string;
browseLabel?: string;
}
export const NOT_FOUND_DEFAULTS = {
code: "404",
title: "Page not found",
description:
"The page you are looking for moved, vanished, or never existed.",
homeHref: "/",
homeLabel: "Back home",
browseHref: "/components/motion",
browseLabel: "Browse components",
} as const;
type ActionsProps = Pick<
NotFoundProps,
"homeHref" | "homeLabel" | "browseHref" | "browseLabel" | "className"
>;
/** The shared dual CTA: a primary "Back home" and a secondary "Browse". */
export function NotFoundActions({
homeHref = NOT_FOUND_DEFAULTS.homeHref,
homeLabel = NOT_FOUND_DEFAULTS.homeLabel,
browseHref = NOT_FOUND_DEFAULTS.browseHref,
browseLabel = NOT_FOUND_DEFAULTS.browseLabel,
className,
}: ActionsProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const whileTap = reduce ? undefined : { scale: 0.96 };
const whileHover = reduce || !canHover ? undefined : { scale: 1.02 };
return (
<div
className={cn(
"flex flex-wrap items-center justify-center gap-3",
className,
)}
>
<motion.a
href={homeHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full bg-primary px-6 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{homeLabel}
</motion.a>
<motion.a
href={browseHref}
whileTap={whileTap}
whileHover={whileHover}
transition={SPRING_PRESS}
className="inline-flex h-11 select-none items-center justify-center rounded-full border border-border bg-card px-6 text-sm font-medium text-foreground transition-colors hover:bg-primary/5"
>
{browseLabel}
</motion.a>
</div>
);
}
/** Centers a variant and gives it a consistent minimum stage height. */
export function NotFoundStage({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"flex min-h-[420px] w-full flex-col items-center justify-center gap-8 px-4 text-center",
className,
)}
>
{children}
</div>
);
}
"use client";
import { motion, type Transition, useInView, useReducedMotion } from "motion/react";
import { useRef, type ElementType, type ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
type SplitMode = "word" | "char";
export interface TextRevealProps {
text: string | string[];
as?: ElementType;
className?: string;
split?: SplitMode;
stagger?: number;
delay?: number;
blur?: number;
yOffset?: string | number;
spring?: { stiffness?: number; damping?: number; mass?: number };
once?: boolean;
whileInView?: boolean;
children?: ReactNode;
}
const DEFAULT_SPRING = { stiffness: 140, damping: 26, mass: 1.2 };
type WordGroup = { text: string; trailing: string };
/**
* One tokenizer for both modes: a line becomes the words it is made of, each
* carrying the whitespace that follows it. Word mode animates a group at a
* time, char mode the characters inside one — so the two can't drift apart on
* what counts as a word or where a space belongs. Runs of whitespace and tabs
* survive as their own group rather than collapsing.
*/
function toWordGroups(line: string): WordGroup[] {
const chunks = line.match(/\S+\s*|\s+/g) ?? [];
return chunks.map((chunk) => {
const text = chunk.replace(/\s+$/, "");
return { text, trailing: chunk.slice(text.length) };
});
}
export function TextReveal({
text,
as: Comp = "span",
className,
split = "word",
stagger = 0.09,
delay = 0,
blur = 12,
yOffset = "40%",
spring,
once = true,
whileInView = false,
children,
}: TextRevealProps) {
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once, amount: 0.4 });
const reduce = useReducedMotion();
const shouldAnimate = whileInView ? inView : true;
const lines = Array.isArray(text) ? text : [text];
const s = { ...DEFAULT_SPRING, ...spring };
let unitIndex = 0;
const lineCounts = new Map<string, number>();
return (
<Comp ref={ref} className={cn("block", className)}>
{lines.map((line) => {
const lineCount = lineCounts.get(line) ?? 0;
lineCounts.set(line, lineCount + 1);
const lineKey = `${line}-${lineCount}`;
const unitCounts = new Map<string, number>();
const renderUnit = (unit: string) => {
const d = delay + unitIndex * stagger;
unitIndex += 1;
const unitCount = unitCounts.get(unit) ?? 0;
unitCounts.set(unit, unitCount + 1);
const unitKey = `${unit}-${unitCount}`;
const initial = reduce
? { opacity: 0 }
: { y: yOffset, opacity: 0, filter: `blur(${blur}px)` };
const animate = shouldAnimate
? reduce
? { opacity: 1 }
: { y: 0, opacity: 1, filter: "blur(0px)" }
: initial;
const transition: Transition = reduce
? { opacity: { duration: 0.25, ease: EASE_OUT, delay: d * 0.3 } }
: {
y: { type: "spring" as const, ...s, delay: d },
opacity: { duration: 0.7, ease: EASE_OUT, delay: d },
filter: { duration: 0.9, ease: EASE_OUT, delay: d },
};
return (
<motion.span
key={unitKey}
initial={initial}
animate={animate}
transition={transition}
// `whitespace-pre` is load-bearing: a unit's trailing space is
// inside an inline-block and would otherwise collapse to zero
// width, running every word together.
className="inline-block whitespace-pre will-change-transform"
>
{unit}
</motion.span>
);
};
const groups = toWordGroups(line);
const groupCounts = new Map<string, number>();
return (
<span key={lineKey} className="block">
{groups.map((group) => {
const whole = group.text + group.trailing;
// Characters animate one at a time, but each word (plus the
// space that follows it) sits in its own inline-block so a long
// line wraps between words instead of mid-word.
if (split !== "char") return renderUnit(whole);
const groupCount = groupCounts.get(whole) ?? 0;
groupCounts.set(whole, groupCount + 1);
return (
<span
key={`${whole}-${groupCount}`}
className="inline-block whitespace-pre"
>
{Array.from(whole).map((char) => renderUnit(char))}
</span>
);
})}
</span>
);
})}
{children}
</Comp>
);
}
API Reference
className?string—code?stringThe big status code.
404title?stringPage not founddescription?stringThe page you are looking for moved, vanished, or never existed.homeHref?string—homeLabel?string—browseHref?string—browseLabel?string—Related components
Dynamic Island
iOS-style island pill that morphs between live activity views with bouncy shell resize and blur crossfades.
Swipeable List
Mobile-style list rows that swipe left or right to reveal contextual action buttons.
Fixtures
Animated tournament fixtures in two styles: a knockout bracket that pages through rounds, and a wheel that wraps the same tree around the champion. Both read the same array of rounds, so one dataset draws either.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated