{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wordmark-footer",
  "type": "registry:block",
  "title": "Wordmark Footer",
  "description": "A footer with a giant wordmark that assembles as you scroll into it: each letter rises, unmasks or grows on its own spring, staggered along the scroll so it folds away again when you scroll back. Link columns with drawn underlines and a sprung back-to-top button.",
  "docs": "Wordmark Footer was added to public/vividsites/wordmark-footer.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/wordmark-footer",
  "categories": [
    "ui",
    "navigation"
  ],
  "files": [
    {
      "path": "components/vividsites/wordmark-footer.html",
      "type": "registry:file",
      "target": "public/vividsites/wordmark-footer.html",
      "content": "<!--\nvs-wordmark-footer: a site footer whose giant wordmark is scrubbed by the scroll. As the\n  footer comes into view its letters rise out of a mask (or unmask, or grow) one after\n  another; the stagger is tied to scroll progress, so scrolling back up folds them away\n  again, and every letter rides its own spring so a fast flick settles with a little\n  overshoot. The wordmark is sized to span the footer exactly, and letters lift in a wave\n  under the pointer. Link columns draw an underline in from the left and send it out to the\n  right; the back-to-top button spring-scrolls to the top.\n\n  Markup:  <footer class=\"vs-wordmark-footer\" data-reveal=\"rise\"> ... see below\n  Attributes:\n    data-reveal     \"rise\" (default), \"mask\" (a window slides up over each letter) or \"scale\"\n    data-scroller   selector of the scroll container; default: nearest scrolling parent, else the window\n    .vs-wordmark-footer-mark[data-text]   the wordmark text (change it and it rebuilds)\n    [data-vs-clock][data-tz]              optional live clock, e.g. data-tz=\"Europe/London\"\n  JS: footer.__vsWf.refresh() after replacing the wordmark; window.vsWordmarkFooter.mount(el).\n  Style with --vs-wordmark-footer-bg, -ink, -mark, -display, -text, -pad, -min-h.\n  Reduced motion: letters and scrolling jump straight to their final state.\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@700&display=swap\" rel=\"stylesheet\">\n<link href=\"https://api.fontshare.com/v2/css?f[]=general-sans@400,500&display=swap\" rel=\"stylesheet\">\n<footer class=\"vs-wordmark-footer\" data-reveal=\"rise\">\n  <div class=\"vs-wordmark-footer-top\">\n    <nav class=\"vs-wordmark-footer-cols\" aria-label=\"Footer\">\n      <div class=\"vs-wordmark-footer-col\"><h3>Studio</h3>\n        <ul><li><a href=\"#\">Work</a></li><li><a href=\"#\">Process</a></li><li><a href=\"#\">Journal</a></li><li><a href=\"#\">Careers</a></li></ul></div>\n      <div class=\"vs-wordmark-footer-col\"><h3>Practice</h3>\n        <ul><li><a href=\"#\">Identity</a></li><li><a href=\"#\">Spaces</a></li><li><a href=\"#\">Objects</a></li><li><a href=\"#\">Print</a></li></ul></div>\n      <div class=\"vs-wordmark-footer-col\"><h3>Visit</h3>\n        <address><span>14 Calder Row</span><span>Harbour Quarter, Ostmere</span><a href=\"mailto:hello@morrow.studio\">hello@morrow.studio</a></address></div>\n      <div class=\"vs-wordmark-footer-col\"><h3>Elsewhere</h3>\n        <ul><li><a class=\"is-ext\" href=\"#\">Newsletter</a></li><li><a class=\"is-ext\" href=\"#\">Reading list</a></li><li><a class=\"is-ext\" href=\"#\">Press kit</a></li></ul></div>\n    </nav>\n    <button class=\"vs-wordmark-footer-up\" type=\"button\"><span>Back to top</span><i aria-hidden=\"true\">\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 13.5v-11M3.5 7 8 2.5 12.5 7\"/></svg>\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M8 13.5v-11M3.5 7 8 2.5 12.5 7\"/></svg></i></button>\n  </div>\n  <div class=\"vs-wordmark-footer-mark\" data-text=\"Morrow\">Morrow</div>\n  <div class=\"vs-wordmark-footer-base\">\n    <span class=\"vs-wordmark-footer-copy\">&copy; 2026 Morrow Studio</span>\n    <span>Ostmere <span data-vs-clock data-tz=\"Europe/London\">--:--</span></span>\n    <nav aria-label=\"Legal\"><a href=\"#\">Privacy</a><a href=\"#\">Terms</a><a href=\"#\">Colophon</a></nav>\n  </div>\n</footer>\n\n<style>\n.vs-wordmark-footer {\n  --vs-wordmark-footer-bg: #ebe5d9;              /* the footer surface */\n  --vs-wordmark-footer-ink: #161412;             /* links and small type */\n  --vs-wordmark-footer-mark: var(--vs-wordmark-footer-ink); /* the giant wordmark */\n  --vs-wordmark-footer-line: color-mix(in srgb, var(--vs-wordmark-footer-ink) 16%, transparent);\n  --vs-wordmark-footer-display: \"Oswald\", \"Arial Narrow\", Impact, sans-serif;\n  --vs-wordmark-footer-text: \"General Sans\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif;\n  --vs-wordmark-footer-pad: clamp(1.25rem, 3.4vw, 3rem);\n  --vs-wordmark-footer-min-h: 100vh;\n\n  position: relative; box-sizing: border-box; display: flex; flex-direction: column; min-height: var(--vs-wordmark-footer-min-h);\n  padding: calc(var(--vs-wordmark-footer-pad) * 1.15) var(--vs-wordmark-footer-pad) calc(var(--vs-wordmark-footer-pad) * .7);\n  background: var(--vs-wordmark-footer-bg); color: var(--vs-wordmark-footer-ink); overflow: hidden;\n  font: 400 clamp(.875rem, .8rem + .2vw, 1rem)/1.45 var(--vs-wordmark-footer-text); -webkit-font-smoothing: antialiased;\n}\n.vs-wordmark-footer *, .vs-wordmark-footer *::before, .vs-wordmark-footer *::after { box-sizing: border-box; }\n\n.vs-wordmark-footer-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 2.5rem; }\n.vs-wordmark-footer-cols { flex: 1; max-width: 54rem; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr); gap: 1.6rem clamp(1.25rem, 4vw, 4.5rem); margin: 0; }\n.vs-wordmark-footer-col h3 {\n  margin: 0 0 1.05rem; font-size: .72rem; font-weight: 500; letter-spacing: .1em; text-transform: uppercase; opacity: .5;\n}\n.vs-wordmark-footer-col ul { margin: 0; padding: 0; list-style: none; display: grid; gap: .42rem; }\n.vs-wordmark-footer-col address { font-style: normal; display: grid; gap: .42rem; overflow-wrap: anywhere; }\n.vs-wordmark-footer a {\n  position: relative; color: inherit; text-decoration: none; width: fit-content; outline: none; border-radius: 2px;\n}\n.vs-wordmark-footer-col a { font-weight: 500; }\n.vs-wordmark-footer-col a::after { /* the underline: draws in from the left, leaves to the right */\n  content: \"\"; position: absolute; left: 0; right: 0; bottom: -.08em; height: 1px; background: currentColor;\n  transform: scaleX(0); transform-origin: 100% 50%; transition: transform .5s cubic-bezier(.2,.8,.2,1);\n}\n.vs-wordmark-footer-col a:hover::after, .vs-wordmark-footer-col a:focus-visible::after { transform: scaleX(1); transform-origin: 0 50%; }\n.vs-wordmark-footer a:focus-visible { box-shadow: 0 0 0 2px var(--vs-wordmark-footer-bg), 0 0 0 3.5px var(--vs-wordmark-footer-ink); }\n.vs-wordmark-footer-col .is-ext::before { content: \"\\2197\"; position: absolute; right: -1.05em; top: 0; font-size: .8em; opacity: .45; transition: transform .35s cubic-bezier(.2,.8,.2,1), opacity .2s; }\n.vs-wordmark-footer-col .is-ext:hover::before { transform: translate(.15em, -.15em); opacity: 1; }\n\n/* back to top */\n.vs-wordmark-footer-up {\n  all: unset; box-sizing: border-box; position: relative; flex: none; display: inline-flex; align-items: center; gap: .9rem; cursor: pointer;\n  font-size: .72rem; font-weight: 500; letter-spacing: .1em; text-transform: uppercase; -webkit-tap-highlight-color: transparent;\n}\n.vs-wordmark-footer-up i {\n  position: relative; display: grid; place-items: center; width: 3.4rem; height: 3.4rem; border-radius: 50%; overflow: hidden;\n  box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-wordmark-footer-ink) 40%, transparent), inset 0 0 0 1px var(--vs-wordmark-footer-line);\n  transition: transform .45s cubic-bezier(.3,1.6,.5,1);\n}\n.vs-wordmark-footer-up i::before { /* the fill that rises on hover */\n  content: \"\"; position: absolute; inset: 0; border-radius: 50%; background: var(--vs-wordmark-footer-ink);\n  transform: translateY(101%); transition: transform .5s cubic-bezier(.2,.8,.2,1);\n}\n.vs-wordmark-footer-up svg { position: relative; grid-area: 1 / 1; width: 1.05rem; height: 1.05rem; transition: transform .5s cubic-bezier(.7,0,.2,1), color .3s; }\n.vs-wordmark-footer-up svg + svg { transform: translateY(2.6rem); }\n.vs-wordmark-footer-up:hover i::before, .vs-wordmark-footer-up:focus-visible i::before { transform: translateY(0); }\n.vs-wordmark-footer-up:hover svg, .vs-wordmark-footer-up:focus-visible svg { color: var(--vs-wordmark-footer-bg); transform: translateY(-2.6rem); }\n.vs-wordmark-footer-up:hover svg + svg, .vs-wordmark-footer-up:focus-visible svg + svg { transform: translateY(0); }\n.vs-wordmark-footer-up:active i { transform: scale(.92); transition-duration: .12s; }\n.vs-wordmark-footer-up:focus-visible i { outline: 1.5px solid var(--vs-wordmark-footer-ink); outline-offset: 3px; }\n\n/* the wordmark */\n.vs-wordmark-footer-mark {\n  position: relative; margin: auto 0 0; padding: 2rem 0 .04em; color: var(--vs-wordmark-footer-mark);\n  font: 700 16vw/.84 var(--vs-wordmark-footer-display); letter-spacing: -.012em; text-transform: uppercase; white-space: nowrap; user-select: none; -webkit-user-select: none;\n}\n.vs-wordmark-footer-word { display: inline-block; margin-left: -.02em; }\n.vs-wordmark-footer-l { display: inline-block; vertical-align: top; padding: .02em .09em .07em; margin: 0 -.09em -.07em; will-change: transform; }\n.vs-wordmark-footer[data-reveal=\"rise\"] .vs-wordmark-footer-l,\n.vs-wordmark-footer[data-reveal=\"mask\"] .vs-wordmark-footer-l { overflow: hidden; }\n.vs-wordmark-footer-l > span { display: inline-block; transform-origin: 0 100%; will-change: transform; }\n.vs-wordmark-footer-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n\n.vs-wordmark-footer-base {\n  display: flex; justify-content: space-between; align-items: center; gap: 1rem 2rem; flex-wrap: wrap;\n  margin-top: 1.3rem; padding-top: 1rem; border-top: 1px solid var(--vs-wordmark-footer-line); font-size: .8rem;\n}\n.vs-wordmark-footer-base > span { opacity: .6; font-variant-numeric: tabular-nums; }\n.vs-wordmark-footer-base nav { display: flex; gap: 1.4rem; }\n.vs-wordmark-footer-base nav a { opacity: .6; transition: opacity .15s cubic-bezier(.2,0,0,1); }\n.vs-wordmark-footer-base nav a:hover, .vs-wordmark-footer-base nav a:focus-visible { opacity: 1; }\n\n@media (max-width: 760px) {\n  .vs-wordmark-footer-top { flex-direction: column; gap: 2.4rem; }\n  .vs-wordmark-footer-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.8rem 1.5rem; width: 100%; }\n  .vs-wordmark-footer-base { font-size: .75rem; }\n}\n@media (forced-colors: active) { .vs-wordmark-footer-up i::before { background: CanvasText; } }\n</style>\n\n<script>\n(() => {\n  if (window.vsWordmarkFooter) return;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const live = new Set(); let raf = 0, last = 0;\n  const tick = now => {\n    const dt = Math.min(.064, (now - last) / 1000 || .016); last = now;\n    for (const s of live) if (!s.update(dt)) live.delete(s);\n    raf = live.size ? requestAnimationFrame(tick) : 0;\n  };\n  const wake = s => { live.add(s); if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } };\n  const sp = (x, k, z, eps = .0008) => ({ x, v: 0, g: x, k, z, eps });\n  const step = (s, h) => { s.v += (-s.k * (s.x - s.g) - 2 * Math.sqrt(s.k) * s.z * s.v) * h; s.x += s.v * h; };\n  const clamp = (x, a, b) => Math.min(b, Math.max(a, x));\n  const scrollParent = el => {\n    for (let p = el.parentElement; p && p !== document.body && p !== document.documentElement; p = p.parentElement) {\n      const o = getComputedStyle(p).overflowY; if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;\n    }\n    return null;\n  };\n\n  function mount(foot) {\n    if (foot.__vsWf) return foot.__vsWf;\n    const mark = foot.querySelector('.vs-wordmark-footer-mark');\n    const sel = foot.dataset.scroller;\n    let scroller = sel ? document.querySelector(sel) : scrollParent(foot);\n    const evTarget = () => scroller || window;\n    let L = [], word = null, fs = 100, hover = null, glide = null;\n\n    function build() {\n      const text = (mark.dataset.text || mark.textContent).trim();\n      mark.dataset.text = text;\n      mark.innerHTML = `<span class=\"vs-wordmark-footer-sr\">${text}</span><span class=\"vs-wordmark-footer-word\" aria-hidden=\"true\">` +\n        [...text].map(c => `<span class=\"vs-wordmark-footer-l\"><span>${c === ' ' ? '&nbsp;' : c}</span></span>`).join('') + '</span>';\n      word = mark.lastChild;\n      L = [...word.children].map(o => ({ o, i: o.firstChild, s: sp(0, 150, .7), lift: sp(0, 320, .55) }));\n      fit(); progress(true);\n    }\n    /* size the wordmark so it spans the footer's inner width exactly */\n    function fit() {\n      if (!word) return;\n      mark.style.fontSize = '100px';\n      const w = word.getBoundingClientRect().width, avail = mark.clientWidth;\n      fs = w ? Math.max(24, 100 * avail / w) : 100;\n      mark.style.fontSize = fs.toFixed(2) + 'px';\n    }\n    /* scroll progress of the wordmark into view, spread across the letters */\n    function progress(snap) {\n      const r = mark.getBoundingClientRect(), vb = scroller ? scroller.getBoundingClientRect().bottom : innerHeight;\n      const p = clamp((vb - r.top) / (r.height * .98), 0, 1), n = L.length, S = n > 1 ? .55 : 0;\n      L.forEach((l, k) => {\n        l.s.g = clamp((p - (n > 1 ? k / (n - 1) : 0) * S) / (1 - S), 0, 1);\n        if (snap === true || reduce.matches) { l.s.x = l.s.g; l.s.v = 0; }\n      });\n      wake(api);\n    }\n    const api = {\n      update(dt) {\n        const n = Math.max(1, Math.round(dt * 240)), h = dt / n;\n        let moving = false;\n        if (glide) {\n          step(glide, dt);\n          if (Math.abs(glide.v) < 2 && Math.abs(glide.x) < .5) { setTop(0); glide = null; } else { setTop(glide.x); moving = true; }\n        }\n        const mode = foot.dataset.reveal || 'rise', hx = hover;\n        const wr = hx != null ? word.getBoundingClientRect() : null;\n        for (const l of L) {\n          if (wr) { const c = l.o.offsetLeft + l.o.offsetWidth / 2, d = (hx - wr.left - c) / (fs * .55); l.lift.g = Math.exp(-d * d); }\n          else l.lift.g = 0;\n          for (const s of [l.s, l.lift]) {\n            if (Math.abs(s.v) < s.eps && Math.abs(s.x - s.g) < s.eps) { s.x = s.g; s.v = 0; continue; }\n            moving = true;\n            if (reduce.matches) { s.x = s.g; s.v = 0; continue; }\n            for (let i = 0; i < n; i++) step(s, h);\n          }\n          const t = l.s.x, u = 1 - t, lift = -l.lift.x * fs * .06;\n          if (mode === 'mask') {            // the window slides up over a letter that stays put\n            l.o.style.transform = `translate3d(0,calc(${(u * 100).toFixed(3)}% + ${lift.toFixed(2)}px),0)`;\n            l.i.style.transform = `translate3d(0,${(-u * 100).toFixed(3)}%,0)`; l.i.style.opacity = '';\n          } else if (mode === 'scale') {    // letters grow up from the baseline\n            l.o.style.transform = `translate3d(0,${lift.toFixed(2)}px,0)`;\n            l.i.style.transform = `translate3d(0,${(u * 18).toFixed(3)}%,0) scale(${(.28 + .72 * t).toFixed(4)})`;\n            l.i.style.opacity = clamp(t * 1.6, 0, 1).toFixed(3);\n          } else {                          // rise: out of a mask, with a slight lean\n            l.o.style.transform = `translate3d(0,${lift.toFixed(2)}px,0)`;\n            l.i.style.transform = `translate3d(0,${(u * 108).toFixed(3)}%,0) rotate(${(u * 6).toFixed(3)}deg)`; l.i.style.opacity = '';\n          }\n        }\n        return moving;\n      },\n      refresh: () => build(),\n    };\n\n    const setTop = y => scroller ? (scroller.scrollTop = y) : window.scrollTo(0, y);\n    const getTop = () => scroller ? scroller.scrollTop : scrollY;\n    const up = foot.querySelector('.vs-wordmark-footer-up');\n    up && up.addEventListener('click', e => {\n      e.preventDefault();\n      if (reduce.matches) { setTop(0); return; }\n      glide = { x: getTop(), v: 0, g: 0, k: 70, z: 1 }; wake(api);\n    });\n    const stop = () => { glide = null; };\n    const listen = t => { t.addEventListener('scroll', progress, { passive: true }); t.addEventListener('wheel', stop, { passive: true }); t.addEventListener('touchstart', stop, { passive: true }); };\n    listen(evTarget());\n\n    mark.addEventListener('pointermove', e => { if (e.pointerType === 'mouse') { hover = e.clientX; wake(api); } });\n    mark.addEventListener('pointerleave', () => { hover = null; wake(api); });\n\n    /* optional live clock: <span data-vs-clock data-tz=\"Europe/London\"></span> */\n    const clock = foot.querySelector('[data-vs-clock]');\n    if (clock) {\n      let f; try { f = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: clock.dataset.tz || undefined }); } catch (_) { f = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit' }); }\n      const put = () => { clock.textContent = f.format(new Date()); }; put(); setInterval(put, 20000);\n    }\n\n    new MutationObserver(() => { build(); }).observe(foot, { attributes: true, attributeFilter: ['data-reveal'] });\n    new MutationObserver(ms => { if (ms.some(m => m.attributeName === 'data-text' && mark.dataset.text !== (word && word.textContent.replace(/ /g, ' ')))) build(); })\n      .observe(mark, { attributes: true, attributeFilter: ['data-text'] });\n    new ResizeObserver(() => { fit(); progress(); }).observe(foot);\n    if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { fit(); progress(); });\n    addEventListener('load', () => { if (!sel) { const s = scrollParent(foot); if (s && s !== scroller) { scroller = s; listen(s); } } fit(); progress(); });\n\n    foot.__vsWf = api;\n    build();\n    return api;\n  }\n  const init = () => document.querySelectorAll('.vs-wordmark-footer').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsWordmarkFooter = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Navigation",
    "tags": [
      "footer",
      "wordmark",
      "scroll",
      "reveal",
      "typography",
      "links",
      "navigation"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [],
    "source": null,
    "free": true,
    "accent": "#e2452b",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/wordmark-footer/index.html",
    "thumbnail": "https://vividsites.app/api/component/wordmark-footer/thumb.jpg",
    "video": "https://vividsites.app/api/component/wordmark-footer/thumb.mp4",
    "page": "https://vividsites.app/c/wordmark-footer"
  }
}