{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "not-found",
  "type": "registry:block",
  "title": "404 Not Found",
  "description": "A 404 block whose digits hang from a rail on real rope physics: grab one and throw it, and it swings, knocks its neighbours and settles, with strings that go slack when lifted. A quick sweep of the pointer sets them swaying, and the arrow keys give them a push.",
  "docs": "404 Not Found was added to public/vividsites/not-found.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/not-found",
  "categories": [
    "ui",
    "marketing-blocks"
  ],
  "files": [
    {
      "path": "components/vividsites/not-found.html",
      "type": "registry:file",
      "target": "public/vividsites/not-found.html",
      "content": "<!--\n  Not Found (404) — vividsites.app\n  The three digits hang from a rail on real strings (Verlet rope, fixed 1/120 s steps). Grab one\n  and throw it: it swings, knocks into its neighbours and settles; lift it and its string goes\n  slack. Sweep the pointer past quickly and the draught sets them swaying. With the field focused,\n  ← → give them a push. Below: the message and the way home.\n\n  Markup: .vs-not-found > .vs-not-found-field (rail + span.vs-not-found-digit ×n) + .vs-not-found-copy\n  Options (data attributes on .vs-not-found): data-gravity=\"2400\" (px/s²), data-breeze=\"1\" (0 turns the idle sway off)\n  The section paints its own backdrop and fills the screen, so it works pasted into any page.\n  CSS variables:\n    --vs-not-found-bg         the section's background (cobalt by default; any colour, gradient or image)\n    --vs-not-found-min-height how tall the section is (default 100svh: a whole 404 page)\n    --vs-not-found-digit      digit colour\n    --vs-not-found-ink        text colour\n    --vs-not-found-muted      secondary text\n    --vs-not-found-string     string and rail colour\n    --vs-not-found-size       digit size (the field scales with it)\n    --vs-not-found-btn / --vs-not-found-btn-ink   primary button\n    --vs-not-found-font       body font (Mulish, loaded below; falls back to system-ui)\n    --vs-not-found-display    font for the digits and title (Oswald; falls back to a narrow sans)\n  Reduced motion: the digits hang still; everything else works.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=Oswald:wght@500;700&family=Mulish:wght@400;600&display=swap\" rel=\"stylesheet\">\n<style>\n.vs-not-found {\n  --vs-not-found-digit: #fff3dc;\n  --vs-not-found-ink: #fff3dc;\n  --vs-not-found-muted: rgba(255,243,220,.68);\n  --vs-not-found-string: rgba(255,243,220,.55);\n  --vs-not-found-size: clamp(7rem, 22vw, 15rem);\n  --vs-not-found-btn: #fff3dc;\n  --vs-not-found-btn-ink: #1c26b8;\n  --vs-not-found-font: \"Mulish\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n  --vs-not-found-display: \"Oswald\", \"Arial Narrow\", \"Helvetica Neue\", Impact, sans-serif;\n  --vs-not-found-bg: radial-gradient(70% 60% at 50% 40%, #3342ee, rgba(51,66,238,0) 75%), linear-gradient(180deg, #2130d8, #1a27b4) #2130d8;\n  --vs-not-found-min-height: 100svh;\n\n  position: relative; isolation: isolate; overflow: clip;\n  display: grid; justify-items: center; align-content: center; width: 100%;\n  min-height: 100vh; min-height: var(--vs-not-found-min-height); padding: clamp(2.5rem, 7vh, 5rem) 0;\n  background: var(--vs-not-found-bg);\n  font-family: var(--vs-not-found-font); color: var(--vs-not-found-ink); text-align: center; -webkit-font-smoothing: antialiased;\n}\n.vs-not-found, .vs-not-found * { box-sizing: border-box; }\n.vs-not-found-field {\n  position: relative; width: 100%; max-width: calc(var(--vs-not-found-size) * 3.2); height: calc(var(--vs-not-found-size) * 1.62);\n  touch-action: none; outline: none; border-radius: 16px; -webkit-user-select: none; user-select: none;\n}\n.vs-not-found-field:focus-visible { box-shadow: 0 0 0 2px var(--vs-not-found-string); }\n.vs-not-found-rail { position: absolute; left: 4%; right: 4%; top: 0; height: 2px; border-radius: 2px; background: var(--vs-not-found-string); }\n.vs-not-found-rail::before, .vs-not-found-rail::after { content: \"\"; position: absolute; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--vs-not-found-string); }\n.vs-not-found-rail::before { left: -4px; } .vs-not-found-rail::after { right: -4px; }\n.vs-not-found-strings { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }\n.vs-not-found-strings path { fill: none; stroke: var(--vs-not-found-string); stroke-width: 1.5; stroke-linecap: round; }\n.vs-not-found-strings circle { fill: var(--vs-not-found-digit); }\n.vs-not-found-digit {\n  position: absolute; left: 0; top: 0; display: block; padding: .06em .02em 0;\n  font-family: var(--vs-not-found-display); font-weight: 700; font-size: var(--vs-not-found-size); line-height: .86; letter-spacing: 0;\n  color: var(--vs-not-found-digit); transform-origin: 50% 0; will-change: transform; cursor: grab;\n  text-shadow: 0 .06em .12em rgba(0,0,30,.28);\n}\n.vs-not-found-digit.is-held { cursor: grabbing; }\n.vs-not-found-copy { display: grid; justify-items: center; gap: .9rem; margin-top: 1.25rem; max-width: 34rem; padding: 0 1rem; }\n.vs-not-found-copy h1 { margin: 0; font-family: var(--vs-not-found-display); font-weight: 500; font-size: clamp(1.5rem, 3.4vw, 2.1rem); line-height: 1.1; letter-spacing: .01em; text-transform: uppercase; }\n.vs-not-found-copy p { margin: 0; font-size: 1rem; line-height: 1.55; color: var(--vs-not-found-muted); text-wrap: balance; }\n.vs-not-found-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: .6rem; margin-top: .6rem; }\n.vs-not-found-actions a {\n  display: inline-flex; align-items: center; gap: .5em; height: 2.9rem; padding: 0 1.3rem; border-radius: 999px; text-decoration: none; outline: none;\n  font-weight: 600; font-size: .9375rem; color: var(--vs-not-found-ink); box-shadow: inset 0 0 0 1px var(--vs-not-found-string);\n  transition: transform .45s cubic-bezier(.3,1.5,.5,1), background-color .2s, box-shadow .2s; -webkit-tap-highlight-color: transparent;\n}\n.vs-not-found-actions a[data-primary] { background: var(--vs-not-found-btn); color: var(--vs-not-found-btn-ink); box-shadow: inset 0 -1px 0 rgba(0,0,0,.12), 0 8px 20px -10px rgba(0,0,30,.5); }\n.vs-not-found-actions a:hover { transform: translateY(-2px); }\n.vs-not-found-actions a:not([data-primary]):hover { background: color-mix(in srgb, var(--vs-not-found-ink) 8%, transparent); }\n.vs-not-found-actions a:active { transform: scale(.96); transition-duration: .1s; }\n.vs-not-found-actions a:focus-visible { box-shadow: 0 0 0 2px var(--vs-not-found-btn-ink), 0 0 0 4px var(--vs-not-found-btn); }\n.vs-not-found-actions svg { width: 1em; height: 1em; transition: transform .45s cubic-bezier(.3,1.5,.5,1); }\n.vs-not-found-actions a:hover svg { transform: translateX(-3px); }\n.vs-not-found-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n</style>\n\n<section class=\"vs-not-found\" data-gravity=\"2400\" data-breeze=\"1\">\n  <div class=\"vs-not-found-field\" tabindex=\"0\" role=\"img\" aria-label=\"404: three digits hanging on strings. Use the arrow keys to give them a push.\">\n    <i class=\"vs-not-found-rail\"></i>\n    <span class=\"vs-not-found-digit\" aria-hidden=\"true\">4</span>\n    <span class=\"vs-not-found-digit\" aria-hidden=\"true\">0</span>\n    <span class=\"vs-not-found-digit\" aria-hidden=\"true\">4</span>\n  </div>\n  <div class=\"vs-not-found-copy\">\n    <h1>This page came off its hook</h1>\n    <p>The link may be old, or the page has moved. Everything else is still hanging where you left it.</p>\n    <div class=\"vs-not-found-actions\">\n      <a href=\"/\" data-primary><svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M13 8H4M7.5 4 3.5 8l4 4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>Take me home</a>\n      <a href=\"/search\">Search the site</a>\n    </div>\n  </div>\n</section>\n\n<script>\n(() => {\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const NS = 'http://www.w3.org/2000/svg';\n  function mount(root) {\n    if (root.vsNotFound) return root.vsNotFound;\n    const field = root.querySelector('.vs-not-found-field');\n    const els = [...field.querySelectorAll('.vs-not-found-digit')];\n    const svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'vs-not-found-strings'); svg.setAttribute('aria-hidden', 'true');\n    field.insertBefore(svg, els[0]);\n    const D = els.map((el, i) => {\n      const path = document.createElementNS(NS, 'path'), pin = document.createElementNS(NS, 'circle'), knot = document.createElementNS(NS, 'circle');\n      pin.setAttribute('r', 3.5); knot.setAttribute('r', 2.5); svg.append(path, pin, knot);\n      return { el, path, pin, knot, i, x: 0, y: 0, ox: 0, oy: 0, px: 0, py: 0, L: 0, w: 0, h: 0, held: null };\n    });\n    const lengths = [.36, .52, .3];              // string lengths in digit-heights; uneven reads as hand-hung\n    let W = 0, H = 0, g = 2400, raf = 0, last = 0, acc = 0, visible = true, t = 0;\n    const layout = () => {\n      W = field.clientWidth; H = field.clientHeight;\n      const ws = D.map(d => (d.w = d.el.offsetWidth, d.h = d.el.offsetHeight, d.w)), gap = ws.reduce((a, b) => a + b, 0) * .07;\n      const total = ws.reduce((a, b) => a + b, 0) + gap * (D.length - 1); let x0 = (W - total) / 2;\n      D.forEach((d, i) => {\n        const nx = x0 + d.w / 2; x0 += d.w + gap;\n        const nL = d.h * lengths[i % lengths.length] + H * .02;\n        const first = !d.L; const dx = d.x - d.px, dy = d.y - d.py;\n        d.px = nx; d.py = 1; d.L = nL;\n        if (first) { d.x = d.ox = nx + (i - 1) * 6; d.y = d.oy = 1 + nL; }\n        else { const k = nL / Math.max(1, Math.hypot(dx, dy)); d.x = d.ox = nx + dx * k; d.y = d.oy = 1 + dy * k; }\n      });\n      paint();\n    };\n    const opt = () => { g = +root.dataset.gravity || 2400; };\n    // two circles per digit along its axis, for soft collisions between neighbours\n    const probes = d => { const a = Math.atan2(d.x - d.px, d.y - d.py), s = Math.sin(a), c = Math.cos(a), r = d.w * .46;\n      return [.32, .72].map(f => ({ x: d.x + s * d.h * f, y: d.y + c * d.h * f, r })); };\n    const stepH = h => {\n      t += h;\n      const breeze = root.dataset.breeze === '0' || reduce.matches ? 0 : 1;\n      for (const d of D) {\n        if (d.held) { d.ox = d.x; d.oy = d.y; d.x += (d.held.tx - d.x) * .5; d.y += (d.held.ty - d.y) * .5; }\n        else {\n          const vx = (d.x - d.ox) * .9935, vy = (d.y - d.oy) * .9935; d.ox = d.x; d.oy = d.y;\n          const fx = breeze * 38 * Math.sin(t * .9 + d.i * 1.7) * Math.sin(t * .37 + d.i);\n          d.x += vx + fx * h * h; d.y += vy + g * h * h;\n        }\n        // rope: taut when stretched, slack otherwise\n        const dx = d.x - d.px, dy = d.y - d.py, dist = Math.hypot(dx, dy);\n        if (dist > d.L) { d.x = d.px + dx / dist * d.L; d.y = d.py + dy / dist * d.L; }\n      }\n      // neighbours collide (a held digit is immovable, so it shoves the other the full distance)\n      // and never pass through each other, so 4-0-4 always reads in order\n      for (let k = 0; k < 4; k++) for (let i = 0; i < D.length - 1; i++) {\n        const a = D[i], b = D[i + 1]; if (a.held && b.held) continue;\n        const wa = a.held ? 0 : b.held ? 1 : .5, wb = 1 - wa;\n        const pa = probes(a), pb = probes(b);\n        for (const p of pa) for (const q of pb) {\n          const dx = q.x - p.x, dy = q.y - p.y, dist = Math.hypot(dx, dy) || .01, over = p.r + q.r - dist;\n          if (over > 0) { const nx = dx / dist * over, ny = dy / dist * over * .3; a.x -= nx * wa; a.y -= ny * wa; b.x += nx * wb; b.y += ny * wb; }\n        }\n        const gap = (pb[1].x - pa[1].x) - (a.w + b.w) * .42;\n        if (gap < 0) { a.x += gap * wa; b.x -= gap * wb; }\n      }\n      for (const d of D) { const dx = d.x - d.px, dy = d.y - d.py, dist = Math.hypot(dx, dy); if (dist > d.L) { d.x = d.px + dx / dist * d.L; d.y = d.py + dy / dist * d.L; } }\n    };\n    const paint = () => {\n      for (const d of D) {\n        const dx = d.x - d.px, dy = d.y - d.py, dist = Math.hypot(dx, dy), ang = Math.atan2(dx, dy);\n        d.el.style.transform = `translate3d(${(d.x - d.w / 2).toFixed(2)}px,${d.y.toFixed(2)}px,0) rotate(${(-ang * 57.2958).toFixed(2)}deg)`;\n        const sag = Math.max(0, d.L - dist) * .7;\n        const mx = (d.px + d.x) / 2, my = (d.py + d.y) / 2 + sag;\n        d.path.setAttribute('d', `M${d.px.toFixed(1)} ${d.py.toFixed(1)}Q${mx.toFixed(1)} ${my.toFixed(1)} ${d.x.toFixed(1)} ${d.y.toFixed(1)}`);\n        d.pin.setAttribute('cx', d.px.toFixed(1)); d.pin.setAttribute('cy', d.py.toFixed(1));\n        d.knot.setAttribute('cx', d.x.toFixed(1)); d.knot.setAttribute('cy', d.y.toFixed(1));\n      }\n    };\n    const tick = now => {\n      const dt = Math.min(.05, (now - last) / 1000 || .016); last = now; acc += dt;\n      while (acc >= 1 / 120) { stepH(1 / 120); acc -= 1 / 120; }\n      paint(); raf = visible && !reduce.matches ? requestAnimationFrame(tick) : 0;\n    };\n    const wake = () => { if (!raf && visible && !reduce.matches) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n    const local = e => { const r = field.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };\n\n    // grab and throw\n    D.forEach(d => d.el.addEventListener('pointerdown', e => {\n      if (reduce.matches || e.button > 0) return; e.preventDefault();\n      const [x, y] = local(e); d.held = { id: e.pointerId, gx: x - d.x, gy: y - d.y, tx: d.x, ty: d.y };\n      d.el.classList.add('is-held'); try { d.el.setPointerCapture(e.pointerId); } catch (_) {} wake();\n    }));\n    D.forEach(d => {\n      d.el.addEventListener('pointermove', e => { if (!d.held || d.held.id !== e.pointerId) return; const [x, y] = local(e); d.held.tx = x - d.held.gx; d.held.ty = y - d.held.gy; });\n      const up = e => { if (!d.held || d.held.id !== e.pointerId) return; d.held = null; d.el.classList.remove('is-held'); };\n      d.el.addEventListener('pointerup', up); d.el.addEventListener('pointercancel', up);\n    });\n    // a quick sweep of the pointer is a draught\n    let lp = null;\n    field.addEventListener('pointermove', e => {\n      const [x, y] = local(e), now = performance.now();\n      if (lp && !D.some(d => d.held)) {\n        const dt = Math.max(8, now - lp.t), vx = (x - lp.x) / dt, vy = (y - lp.y) / dt;\n        for (const d of D) { const c = probes(d)[1], dist = Math.hypot(c.x - x, c.y - y); if (dist < d.w * 1.4) { const f = (1 - dist / (d.w * 1.4)) * .9; d.ox -= vx * f; d.oy -= vy * f * .3; } }\n      }\n      lp = { x, y, t: now };\n    });\n    field.addEventListener('pointerleave', () => { lp = null; });\n    field.addEventListener('keydown', e => {\n      const k = { ArrowLeft: -1, ArrowRight: 1 }[e.key]; if (!k || reduce.matches) return; e.preventDefault();\n      D.forEach((d, i) => { d.ox -= k * (7 + i * 2); }); wake();\n    });\n    new ResizeObserver(layout).observe(field);\n    new IntersectionObserver(e => { visible = e[0].isIntersecting; wake(); }).observe(field);\n    new MutationObserver(opt).observe(root, { attributes: true, attributeFilter: ['data-gravity'] });\n    reduce.addEventListener?.('change', () => { layout(); wake(); });\n    opt(); (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { layout(); wake(); });\n    layout(); wake();\n    const api = { push(k = 1) { D.forEach((d, i) => { d.ox -= k * (9 + i * 3); }); wake(); }, layout };\n    root.vsNotFound = api; return api;\n  }\n  const init = () => document.querySelectorAll('.vs-not-found').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsNotFound = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Marketing blocks",
    "tags": [
      "404",
      "not found",
      "error page",
      "physics",
      "verlet",
      "drag",
      "playful",
      "empty state"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#2130d8",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/not-found/index.html",
    "thumbnail": "https://vividsites.app/api/component/not-found/thumb.jpg",
    "video": "https://vividsites.app/api/component/not-found/thumb.mp4",
    "page": "https://vividsites.app/c/not-found"
  }
}