Not Found
A 404 section whose giant digits float, dodge the pointer on springs and can be flung around, with search and links home.
Error 404: This page wandered off
The link may be broken, or the page may have moved. Try searching, or head back somewhere familiar.
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/not-found.jsonManual installation
Install the dependencies:
bash
npm install motion-vThen copy these files into components/ui/not-found/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { motion, useMotionValue, useReducedMotion } from 'motion-v'
import { computed, ref, useId } from 'vue'
import { cn } from '@/lib/utils'
import NotFoundDigit from './NotFoundDigit.vue'
export interface NotFoundLink {
label: string
href: string
}
const props = withDefaults(defineProps<{
/** The big code. Each character becomes a springy, draggable glyph. */
code?: string
title?: string
description?: string
/** Links back into the site; the first one is the primary action. */
links?: NotFoundLink[]
/** Show the search field. */
search?: boolean
searchPlaceholder?: string
/** Show a "Go back" button that calls `history.back()`. */
back?: boolean
/** Heading level of the title (use 2 when the page already has an h1). */
headingLevel?: 1 | 2 | 3
class?: HTMLAttributes['class']
}>(), {
code: '404',
title: 'This page wandered off',
description: 'The link may be broken, or the page may have moved. Try searching, or head back somewhere familiar.',
links: () => [{ label: 'Back to home', href: '/' }],
search: true,
searchPlaceholder: 'Search the site…',
back: true,
headingLevel: 1,
})
const emit = defineEmits<{ search: [query: string] }>()
const uid = useId()
const reduced = useReducedMotion()
const query = ref('')
const chars = computed(() => [...props.code])
const accentIndex = computed(() => Math.floor((chars.value.length - 1) / 2))
// Pointer position relative to the section, shared with every glyph.
const px = useMotionValue(Number.POSITIVE_INFINITY)
const py = useMotionValue(Number.POSITIVE_INFINITY)
function onMove(e: PointerEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
px.set(e.clientX - rect.left)
py.set(e.clientY - rect.top)
}
function onLeave() {
px.set(Number.POSITIVE_INFINITY)
py.set(Number.POSITIVE_INFINITY)
}
function submit() {
const q = query.value.trim()
if (q)
emit('search', q)
}
function goBack() {
if (typeof window !== 'undefined')
window.history.back()
}
const enter = (i: number) => ({
initial: { opacity: 0, y: 14 },
animate: { opacity: 1, y: 0 },
transition: { type: 'spring', stiffness: 260, damping: 26, delay: reduced.value ? 0 : 0.15 + i * 0.07 },
})
</script>
<template>
<section
data-vx-nf-root
:class="cn('relative flex w-full flex-col items-center px-4 py-10 text-center', props.class)"
:aria-labelledby="`${uid}-title`"
@pointermove="onMove"
@pointerleave="onLeave"
>
<div
aria-hidden="true"
class="flex select-none items-center justify-center text-[clamp(6rem,24vw,11rem)] font-black leading-none tracking-tighter"
>
<NotFoundDigit
v-for="(c, i) in chars"
:key="`${i}-${c}`"
:char="c"
:index="i"
:px="px"
:py="py"
:accent="i === accentIndex"
/>
</div>
<motion.div v-bind="enter(0)" class="mt-6">
<component :is="`h${props.headingLevel}`" :id="`${uid}-title`" class="m-0 text-2xl font-semibold tracking-tight sm:text-3xl">
<span class="sr-only">Error {{ props.code }}: </span>{{ props.title }}
</component>
</motion.div>
<motion.p v-bind="enter(1)" class="m-0 mt-2 max-w-md text-sm leading-6 text-muted-foreground">
{{ props.description }}
</motion.p>
<motion.form
v-if="props.search"
v-bind="enter(2)"
role="search"
class="mt-6 flex w-full max-w-sm items-center gap-2"
@submit.prevent="submit"
>
<label :for="`${uid}-q`" class="sr-only">Search</label>
<div class="relative flex-1">
<svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
<input
:id="`${uid}-q`"
v-model="query"
type="search"
:placeholder="props.searchPlaceholder"
class="h-10 w-full rounded-lg border bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
</div>
<button
type="submit"
class="h-10 shrink-0 rounded-lg border bg-background px-3.5 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Search
</button>
</motion.form>
<motion.nav v-bind="enter(3)" aria-label="Helpful links" class="mt-5 flex flex-wrap items-center justify-center gap-2">
<a
v-for="(link, i) in props.links"
:key="link.href"
:href="link.href"
:class="cn(
'inline-flex h-9 items-center rounded-lg px-3.5 text-sm font-medium no-underline outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
i === 0 ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'text-foreground hover:bg-muted',
)"
>{{ link.label }}</a>
<button
v-if="props.back"
type="button"
class="inline-flex h-9 items-center gap-1.5 rounded-lg px-3.5 text-sm font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="goBack"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 19-7-7 7-7M19 12H5" /></svg>
Go back
</button>
<slot name="actions" />
</motion.nav>
<p class="m-0 mt-8 text-xs text-muted-foreground/70 motion-reduce:hidden" aria-hidden="true">
Psst — the numbers don't like being poked. Try dragging one.
</p>
</section>
</template>vue
<script setup lang="ts">
import type { MotionValue } from 'motion-v'
import { motion, useDomRef, useReducedMotion, useSpring, useTransform } from 'motion-v'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
// One glyph of the big code. It drifts away from the pointer on a spring,
// bobs gently while idle and can be flung around (it snaps back).
const props = defineProps<{
char: string
index: number
/** Pointer position relative to the section; Infinity when outside. */
px: MotionValue<number>
py: MotionValue<number>
accent?: boolean
}>()
const reduced = useReducedMotion()
const el = useDomRef()
// Resting centre, measured from layout (offsets ignore transforms, so no feedback loop).
const center = ref({ x: 0, y: 0 })
const dragging = ref(false)
let ro: ResizeObserver | undefined
// Walk the offsetParent chain up to the section ([data-vx-nf-root]).
function measure() {
let node = el.value as HTMLElement | null
if (!node)
return
let x = node.offsetWidth / 2
let y = node.offsetHeight / 2
while (node && !node.hasAttribute('data-vx-nf-root')) {
x += node.offsetLeft
y += node.offsetTop
node = node.offsetParent as HTMLElement | null
}
center.value = { x, y }
}
onMounted(() => {
measure()
const root = (el.value as HTMLElement | null)?.closest('[data-vx-nf-root]')
if (root) {
ro = new ResizeObserver(measure)
ro.observe(root)
}
})
onBeforeUnmount(() => ro?.disconnect())
const RANGE = 280
const PUSH = 90
function push([x, y]: number[]) {
if (reduced.value || !Number.isFinite(x!) || !Number.isFinite(y!))
return { dx: 0, dy: 0, force: 0 }
const dx = center.value.x - x!
const dy = center.value.y - y!
const dist = Math.hypot(dx, dy) || 1
const force = Math.max(0, 1 - dist / RANGE) ** 1.6
return { dx: (dx / dist) * force * PUSH, dy: (dy / dist) * force * PUSH, force: Math.sign(dx) * force }
}
const cfg = { stiffness: 150, damping: 14, mass: 0.8 }
const x = useSpring(useTransform([props.px, props.py], v => push(v as number[]).dx), cfg)
const y = useSpring(useTransform([props.px, props.py], v => push(v as number[]).dy), cfg)
const rotate = useSpring(useTransform([props.px, props.py], v => push(v as number[]).force * 14), cfg)
</script>
<template>
<motion.span
:class="cn('relative inline-block', dragging && 'z-10')"
:animate="reduced ? { y: 0 } : { y: [0, -10, 0] }"
:transition="reduced ? { duration: 0 } : { duration: 3.6, repeat: Infinity, ease: 'easeInOut', delay: props.index * 0.45 }"
>
<motion.span ref="el" class="inline-block" :style="{ x, y, rotate }">
<motion.span
drag
drag-snap-to-origin
:drag-elastic="0.6"
:while-drag="{ scale: 1.08, cursor: 'grabbing' }"
:drag-transition="{ bounceStiffness: 260, bounceDamping: 12 }"
@drag-start="dragging = true"
@drag-end="dragging = false"
:class="cn(
'inline-block cursor-grab touch-none bg-gradient-to-b bg-clip-text px-[0.02em] text-transparent',
props.accent ? 'from-brand to-chart-2' : 'from-foreground to-foreground/25',
)"
>{{ props.char }}</motion.span>
</motion.span>
</motion.span>
</template>ts
export { default as NotFound } from './NotFound.vue'
export type { NotFoundLink } from './NotFound.vue'Usage
ts
import { NotFound } from '@/components/ui/not-found'vue
<!-- e.g. pages/[...all].vue or your router's catch-all -->
<NotFound
:links="[{ label: 'Back to home', href: '/' }, { label: 'Contact support', href: '/support' }]"
@search="q => router.push({ path: '/search', query: { q } })"
/>Each character of code is its own glyph: it bobs on an idle loop, springs away from the pointer and can be dragged and flung — it always snaps home. With reduced motion the digits stay still (dragging still works) and the content appears without the entrance stagger.
API
| Prop | Type | Default | Description |
|---|---|---|---|
code | string | '404' | The big code; the middle character gets the brand gradient. |
title | string | 'This page wandered off' | Heading text. |
description | string | — | Supporting copy under the heading. |
links | { label, href }[] | [{ label: 'Back to home', href: '/' }] | Links back into the site; the first is the primary action. |
search | boolean | true | Show the search field. |
searchPlaceholder | string | 'Search the site…' | Search placeholder. |
back | boolean | true | Show a Go back button (history.back()). |
headingLevel | 1 | 2 | 3 | 1 | Heading level of the title. |
| Event | Payload | Description |
|---|---|---|
search | string | Submitted (trimmed, non-empty) search query. |
| Slot | Description |
|---|---|
actions | Extra buttons after the links. |