Type Foundry Hero
Typography hero section with no images: a giant specimen word whose letters swell in weight under the pointer.
- Heroes
- Type & publishing
- React only · 0 deps
- 1 file
Browser support · All evergreen browsers. Weight interpolates continuously on variable system fonts (SF Pro, Segoe UI Variable) and steps on static ones.
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/hero-type-only.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/heroes/hero-type-only.tsx
1/**2 * HeroTypeOnly: a purely typographic hero for a type foundry release. No3 * images. A giant specimen word whose letters swell in weight under the4 * pointer (font-weight interpolation, continuous on variable system fonts5 * such as SF Pro, stepped on static families), a weight ruler that tracks the6 * pointer, a live axis readout and a specimen grid of family metadata set on7 * hairline rules. Strictly black and white.8 *9 * Props: word (specimen word), release, family, meta (label/value pairs),10 * sample (pangram line), price, nav, onTrial.11 * CSS vars: --fd-tyo-ink, --fd-tyo-paper, --fd-tyo-size.12 *13 * Browser support: all evergreen browsers. Pointer Events (mouse, pen and14 * touch drag across the word). When idle the weight sweeps slowly by itself.15 * Motion: one rAF loop eases the pointer with a spring (k 380, c 30), paused16 * off-screen and in hidden tabs. prefers-reduced-motion shows a static17 * 100 to 900 weight ramp across the word and no idle sweep.18 * Accessibility: the word is a real heading with an aria-label; per-letter19 * spans are aria-hidden. The weight ruler is decorative; the readout is text.20 */21import { useEffect, useRef, useState } from "react";22import type { ReactNode } from "react";23 24export interface HeroTypeOnlyMeta {25 label: string;26 value: ReactNode;27}28 29export interface HeroTypeOnlyProps {30 word?: string;31 release?: string;32 family?: string;33 meta?: HeroTypeOnlyMeta[];34 sample?: string;35 price?: string;36 nav?: { label: string; href: string }[];37 trialHref?: string;38 buyHref?: string;39}40 41const DEFAULT_META: HeroTypeOnlyMeta[] = [42 { label: "Family", value: "Brume Grotesk" },43 { label: "Styles", value: "18, nine weights with italics" },44 { label: "Axes", value: "wght 100 to 900, opsz 8 to 144" },45 { label: "Glyphs", value: "1,214 per style" },46 { label: "Scripts", value: "Latin, Cyrillic, Greek, 219 languages" },47 { label: "Drawn by", value: "Ines Vogt, Basel, 2023 to 2026" },48];49 50const DEFAULT_NAV = [51 { label: "Typefaces", href: "#typefaces" },52 { label: "Trials", href: "#trials" },53 { label: "Licensing", href: "#licensing" },54 { label: "Journal", href: "#journal" },55];56 57const W_MIN = 100;58const W_MAX = 900;59 60export function HeroTypeOnly({61 word = "Brume",62 release = "Release No. 14",63 family = "Brume Grotesk",64 meta = DEFAULT_META,65 sample = "Fog lifts off the Rhine at 07:40; the 8 tram to Kleinhüningen is quietly, reliably late.",66 price = "Family from CHF 480",67 nav = DEFAULT_NAV,68 trialHref = "#trial",69 buyHref = "#buy",70}: HeroTypeOnlyProps) {71 const letters = Array.from(word);72 const rootRef = useRef<HTMLElement>(null);73 const wordRef = useRef<HTMLHeadingElement>(null);74 const spanRefs = useRef<(HTMLSpanElement | null)[]>([]);75 const markRef = useRef<HTMLSpanElement>(null);76 const [readout, setReadout] = useState({ w: 500, x: 0.5, live: false });77 const [menu, setMenu] = useState(false);78 79 useEffect(() => {80 const root = rootRef.current;81 const wordEl = wordRef.current;82 if (!root || !wordEl) return;83 const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;84 const spans = spanRefs.current;85 const n = letters.length;86 87 if (reduce) {88 spans.forEach((s, i) => {89 if (!s) return;90 const w = Math.round(W_MIN + ((W_MAX - W_MIN) * i) / Math.max(1, n - 1));91 s.style.fontWeight = String(w);92 });93 return;94 }95 96 // letter centres as fractions of the word width, measured at rest97 let centres: number[] = [];98 const measure = () => {99 const r = wordEl.getBoundingClientRect();100 centres = spans.map((s) => {101 if (!s || r.width === 0) return 0.5;102 const b = s.getBoundingClientRect();103 return (b.left + b.width / 2 - r.left) / r.width;104 });105 };106 measure();107 const ro = new ResizeObserver(measure);108 ro.observe(wordEl);109 110 let target = 0.5;111 let pos = 0.5;112 let vel = 0;113 let lastMove = -1e9;114 let raf = 0;115 let last = performance.now();116 let visible = true;117 let lastReport = 0;118 const t0 = last;119 120 const onMove = (e: PointerEvent) => {121 const r = wordEl.getBoundingClientRect();122 if (r.width === 0) return;123 target = Math.min(1.08, Math.max(-0.08, (e.clientX - r.left) / r.width));124 lastMove = performance.now();125 };126 root.addEventListener("pointermove", onMove);127 root.addEventListener("pointerdown", onMove);128 129 const frame = (now: number) => {130 raf = 0;131 const dt = Math.min(0.032, (now - last) / 1000);132 last = now;133 const idle = now - lastMove > 2600;134 if (idle) target = 0.5 + 0.56 * Math.sin((now - t0) / 2400);135 // spring k 380, c 30 (softer when idle)136 const k = idle ? 60 : 380;137 const c = idle ? 16 : 30;138 vel += (k * (target - pos) - c * vel) * dt;139 pos += vel * dt;140 141 const sigma = 0.2;142 let focusW = W_MIN;143 for (let i = 0; i < n; i++) {144 const s = spans[i];145 if (!s) continue;146 const d = (centres[i] ?? 0.5) - pos;147 const f = Math.exp(-(d * d) / (2 * sigma * sigma));148 const w = W_MIN + (W_MAX - W_MIN) * f;149 s.style.fontWeight = w.toFixed(0);150 if (w > focusW) focusW = w;151 }152 const mark = markRef.current;153 const clamped = Math.min(1, Math.max(0, pos));154 if (mark) mark.style.transform = `translateX(${(clamped * 100).toFixed(2)}cqw)`;155 if (now - lastReport > 90) {156 lastReport = now;157 setReadout({ w: Math.round(focusW), x: clamped, live: !idle });158 }159 if (visible && !document.hidden) raf = requestAnimationFrame(frame);160 };161 const start = () => {162 if (!raf && visible && !document.hidden) {163 last = performance.now();164 raf = requestAnimationFrame(frame);165 }166 };167 const io = new IntersectionObserver(([e]) => {168 visible = e.isIntersecting;169 if (visible) start();170 });171 io.observe(root);172 const onVis = () => start();173 document.addEventListener("visibilitychange", onVis);174 start();175 return () => {176 cancelAnimationFrame(raf);177 ro.disconnect();178 io.disconnect();179 document.removeEventListener("visibilitychange", onVis);180 root.removeEventListener("pointermove", onMove);181 root.removeEventListener("pointerdown", onMove);182 };183 }, [word]); // eslint-disable-line react-hooks/exhaustive-deps184 185 const ticks = [100, 200, 300, 400, 500, 600, 700, 800, 900];186 187 return (188 <section ref={rootRef} className="fd-tyo" aria-labelledby="fd-tyo-word">189 <style>{css}</style>190 <header className="fd-tyo-nav">191 <a className="fd-tyo-logo" href="#top" aria-label="Kessler and Vogt, home">192 <span className="fd-tyo-logo-k">K</span>193 <span className="fd-tyo-logo-amp">&</span>194 <span className="fd-tyo-logo-v">V</span>195 <span className="fd-tyo-logo-word">Kessler & Vogt<br />Type Foundry, Basel</span>196 </a>197 <nav className={`fd-tyo-links${menu ? " is-open" : ""}`} aria-label="Primary" id="fd-tyo-menu">198 {nav.map((l, i) => (199 <a key={l.href} href={l.href} className="fd-tyo-link">200 <span className="fd-tyo-link-n">{String(i + 1).padStart(2, "0")}</span>201 {l.label}202 </a>203 ))}204 </nav>205 <a className="fd-tyo-cart" href="#cart">Cart (0)</a>206 <button207 type="button"208 className="fd-tyo-burger"209 aria-expanded={menu}210 aria-controls="fd-tyo-menu"211 onClick={() => setMenu((m) => !m)}212 >213 {menu ? "Close" : "Menu"}214 </button>215 </header>216 217 <div className="fd-tyo-strip">218 <p className="fd-tyo-release">219 <span className="fd-tyo-dot" aria-hidden="true" />220 {release}221 <span className="fd-tyo-sep">/</span>222 Out 11.11.2026223 </p>224 <p className="fd-tyo-readout" aria-live="off">225 <span>wght</span>226 <b>{String(readout.w).padStart(3, "0")}</b>227 <span>x</span>228 <b>{readout.x.toFixed(2)}</b>229 <span className="fd-tyo-state">{readout.live ? "pointer" : "auto"}</span>230 </p>231 </div>232 233 <h1 ref={wordRef} id="fd-tyo-word" className="fd-tyo-word" aria-label={`${word}, a new typeface by Kessler and Vogt`}>234 {letters.map((ch, i) => (235 <span236 key={i}237 aria-hidden="true"238 className="fd-tyo-ch"239 ref={(el) => {240 spanRefs.current[i] = el;241 }}242 >243 {ch}244 </span>245 ))}246 </h1>247 248 <div className="fd-tyo-ruler" aria-hidden="true">249 <div className="fd-tyo-ruler-track">250 {ticks.map((t) => (251 <span key={t} className={`fd-tyo-tick${t === 900 ? " is-end" : ""}`}>252 <i />253 {t}254 </span>255 ))}256 <span ref={markRef} className="fd-tyo-mark" />257 </div>258 </div>259 260 <dl className="fd-tyo-meta">261 {meta.map((m, i) => (262 <div key={m.label} className="fd-tyo-cell">263 <dt className="fd-tyo-dt">264 <span>{String(i + 1).padStart(2, "0")}</span>265 {m.label}266 </dt>267 <dd className="fd-tyo-dd">{m.value}</dd>268 </div>269 ))}270 </dl>271 272 <div className="fd-tyo-foot">273 <p className="fd-tyo-sample">274 <span className="fd-tyo-sample-tag">{family} Book, 32/36</span>275 {sample}276 </p>277 <div className="fd-tyo-actions">278 <p className="fd-tyo-price">{price}<br />Desktop, web and app licences</p>279 <a className="fd-tyo-btn fd-tyo-btn-solid" href={buyHref}>280 Buy the family281 <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M3 8h9M8.5 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>282 </a>283 <a className="fd-tyo-btn" href={trialHref}>Free trial fonts</a>284 </div>285 </div>286 </section>287 );288}289 290const css = `291.fd-tyo, .fd-tyo *, .fd-tyo *::before, .fd-tyo *::after { box-sizing: border-box; }292.fd-tyo :where(h1, p, a, dl, dt, dd, div, span, b, i, nav, header, button) {293 margin: 0; padding: 0; border: 0; background: none; font: inherit; color: inherit;294 letter-spacing: inherit; text-decoration: none; text-transform: none;295}296.fd-tyo {297 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);298 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);299 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);300 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;301 --fd-tyo-ink: #0A0A0A;302 --fd-tyo-paper: #FFFFFF;303 --fd-tyo-size: clamp(120px, 31vw, 470px);304 --fd-tyo-sans: "SF Pro Display", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif;305 --fd-tyo-serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;306 --fd-tyo-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;307 position: relative; display: flex; flex-direction: column; margin: 0; padding: 0 40px 36px;308 min-height: 880px; background: var(--fd-tyo-paper); color: var(--fd-tyo-ink);309 font: 400 15px/1.4 var(--fd-tyo-sans); letter-spacing: 0; overflow: hidden;310 touch-action: pan-y;311}312 313/* nav */314.fd-tyo-nav {315 position: relative; z-index: 3; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 40px;316 padding: 22px 0 20px; border-bottom: 1px solid var(--fd-tyo-ink);317}318.fd-tyo-logo { display: inline-flex; align-items: center; gap: 0; color: var(--fd-tyo-ink); text-decoration: none; }319.fd-tyo-logo-k, .fd-tyo-logo-v { font: 800 28px/1 var(--fd-tyo-sans); letter-spacing: -0.04em; }320.fd-tyo-logo-amp { font: italic 400 30px/1 var(--fd-tyo-serif); margin: 0 1px; transform: translateY(1px); display: inline-block; }321.fd-tyo-logo-word {322 margin-left: 14px; padding-left: 14px; border-left: 1px solid var(--fd-tyo-ink);323 font: 500 12px/1.25 var(--fd-tyo-mono); letter-spacing: 0.02em; text-transform: uppercase;324}325.fd-tyo-links { display: flex; gap: 28px; }326.fd-tyo-link { position: relative; display: inline-flex; align-items: baseline; gap: 6px; font: 500 15px/1.2 var(--fd-tyo-sans); color: var(--fd-tyo-ink); text-decoration: none; }327.fd-tyo-link-n { font: 400 11px/1 var(--fd-tyo-mono); }328.fd-tyo-link::after {329 content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: currentColor;330 transform: scaleX(0); transform-origin: right; transition: transform var(--fd-dur-2) var(--fd-ease-out);331}332.fd-tyo-link:hover::after { transform: scaleX(1); transform-origin: left; }333.fd-tyo-cart { font: 500 13px/1 var(--fd-tyo-mono); padding: 10px 14px; border: 1px solid var(--fd-tyo-ink); border-radius: 9999px; color: var(--fd-tyo-ink); transition: background var(--fd-dur-1), color var(--fd-dur-1); }334.fd-tyo-cart:hover { background: var(--fd-tyo-ink); color: var(--fd-tyo-paper); }335.fd-tyo-burger { display: none; appearance: none; cursor: pointer; font: 500 13px/1 var(--fd-tyo-mono); padding: 10px 14px; border: 1px solid var(--fd-tyo-ink); border-radius: 9999px; color: var(--fd-tyo-ink); background: var(--fd-tyo-paper); }336 337/* strip */338.fd-tyo-strip { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 16px 0 0; }339.fd-tyo-release, .fd-tyo-readout { display: flex; align-items: center; gap: 10px; font: 500 12px/1.2 var(--fd-tyo-mono); letter-spacing: 0.04em; text-transform: uppercase; }340.fd-tyo-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fd-tyo-ink); display: inline-block; }341.fd-tyo-sep { opacity: 0.4; }342.fd-tyo-readout b { font-weight: 700; min-width: 3.4ch; display: inline-block; font-variant-numeric: tabular-nums; }343.fd-tyo-state { padding: 3px 7px; border: 1px solid var(--fd-tyo-ink); border-radius: 9999px; font-size: 10px; }344 345/* word */346.fd-tyo-word {347 display: flex; justify-content: flex-start; align-items: baseline; margin: 0 0 0 -0.04em; padding: 0;348 font: 400 var(--fd-tyo-size)/0.86 var(--fd-tyo-sans); letter-spacing: -0.035em; color: var(--fd-tyo-ink);349 white-space: nowrap; user-select: none; cursor: ew-resize; flex: 1 0 auto; align-content: center;350 padding-top: clamp(16px, 4vw, 40px);351}352.fd-tyo-ch { display: inline-block; font-weight: 400; font-variation-settings: normal; }353 354/* ruler */355.fd-tyo-ruler { margin-top: clamp(14px, 2vw, 26px); border-top: 1px solid var(--fd-tyo-ink); container-type: inline-size; }356.fd-tyo-ruler-track { position: relative; display: flex; justify-content: space-between; height: 34px; }357.fd-tyo-tick { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; font: 400 10px/1 var(--fd-tyo-mono); width: 0; overflow: visible; white-space: nowrap; }358.fd-tyo-tick i { display: block; width: 1px; height: 8px; background: var(--fd-tyo-ink); }359.fd-tyo-tick.is-end { align-items: flex-end; width: auto; }360.fd-tyo-mark {361 position: absolute; left: 0; top: -5px; width: 0; height: 0;362 border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 7px solid var(--fd-tyo-ink);363 margin-left: -5px; transform: translateX(50cqw);364}365 366/* meta */367.fd-tyo-meta { display: grid; grid-template-columns: repeat(6, 1fr); border-top: 1px solid var(--fd-tyo-ink); border-bottom: 1px solid var(--fd-tyo-ink); margin: 6px 0 0; }368.fd-tyo-cell { padding: 14px 16px 16px 0; border-right: 1px solid var(--fd-tyo-ink); margin-right: 16px; }369.fd-tyo-cell:last-child { border-right: 0; margin-right: 0; }370.fd-tyo-dt { display: flex; gap: 8px; font: 500 11px/1.2 var(--fd-tyo-mono); letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 10px; }371.fd-tyo-dt span { opacity: 0.45; }372.fd-tyo-dd { font: 400 15px/1.35 var(--fd-tyo-sans); letter-spacing: -0.005em; }373 374/* foot */375.fd-tyo-foot { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 40px; padding-top: 26px; }376.fd-tyo-sample { font: 400 clamp(1.375rem, 2.3vw, 2rem)/1.12 var(--fd-tyo-sans); letter-spacing: -0.02em; max-width: 30ch; }377.fd-tyo-sample-tag { display: block; font: 500 11px/1 var(--fd-tyo-mono); letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 12px; }378.fd-tyo-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }379.fd-tyo-price { font: 400 12px/1.4 var(--fd-tyo-mono); margin-right: 12px; text-align: right; }380.fd-tyo-btn {381 display: inline-flex; align-items: center; gap: 10px; height: 48px; padding: 0 22px; border-radius: 9999px;382 border: 1px solid var(--fd-tyo-ink); color: var(--fd-tyo-ink); background: var(--fd-tyo-paper);383 font: 600 15px/1 var(--fd-tyo-sans); text-decoration: none; white-space: nowrap;384 transition: background var(--fd-dur-1), color var(--fd-dur-1), transform var(--fd-dur-2) var(--fd-spring);385}386.fd-tyo-btn svg { display: block; transition: transform var(--fd-dur-2) var(--fd-ease-out); }387.fd-tyo-btn:hover { background: var(--fd-tyo-ink); color: var(--fd-tyo-paper); }388.fd-tyo-btn:hover svg { transform: translateX(3px); }389.fd-tyo-btn:active { transform: scale(0.96); transition-duration: var(--fd-dur-1); }390.fd-tyo-btn-solid { background: var(--fd-tyo-ink); color: var(--fd-tyo-paper); }391.fd-tyo-btn-solid:hover { background: var(--fd-tyo-paper); color: var(--fd-tyo-ink); }392.fd-tyo :focus-visible { outline: 2px solid var(--fd-tyo-ink); outline-offset: 3px; }393 394@media (max-width: 1100px) {395 .fd-tyo-meta { grid-template-columns: repeat(3, 1fr); }396 .fd-tyo-cell:nth-child(3n) { border-right: 0; margin-right: 0; }397 .fd-tyo-cell:nth-child(-n + 3) { border-bottom: 1px solid var(--fd-tyo-ink); }398}399@media (max-width: 760px) {400 .fd-tyo { padding: 0 16px 24px; min-height: 0; --fd-tyo-size: 26vw; }401 .fd-tyo-nav { grid-template-columns: 1fr auto; gap: 12px; padding: 16px 0; }402 .fd-tyo-logo-word { font-size: 10px; margin-left: 10px; padding-left: 10px; }403 .fd-tyo-cart { display: none; }404 .fd-tyo-burger { display: inline-block; }405 .fd-tyo-links {406 display: none; position: absolute; left: 0; right: 0; top: 100%; flex-direction: column; gap: 0;407 background: var(--fd-tyo-paper); border-bottom: 1px solid var(--fd-tyo-ink);408 }409 .fd-tyo-links.is-open { display: flex; }410 .fd-tyo-link { padding: 16px 0; border-top: 1px solid var(--fd-tyo-ink); font-size: 24px; letter-spacing: -0.02em; gap: 12px; }411 .fd-tyo-link::after { display: none; }412 .fd-tyo-strip { flex-direction: column; align-items: flex-start; gap: 8px; }413 .fd-tyo-word { padding-top: 28px; padding-bottom: 6px; flex: 0 0 auto; }414 .fd-tyo-tick { font-size: 9px; }415 .fd-tyo-tick:nth-child(even) { color: transparent; }416 .fd-tyo-meta { grid-template-columns: 1fr 1fr; }417 .fd-tyo-cell, .fd-tyo-cell:nth-child(3n) { border-right: 0; margin-right: 0; border-bottom: 1px solid var(--fd-tyo-ink); padding: 12px 12px 12px 0; }418 .fd-tyo-cell:nth-child(odd) { border-right: 1px solid var(--fd-tyo-ink); margin-right: 12px; }419 .fd-tyo-cell:nth-last-child(-n + 2) { border-bottom: 0; }420 .fd-tyo-dd { font-size: 14px; }421 .fd-tyo-foot { grid-template-columns: 1fr; gap: 22px; padding-top: 22px; }422 .fd-tyo-actions { justify-content: flex-start; }423 .fd-tyo-price { text-align: left; width: 100%; margin: 0; }424 .fd-tyo-btn { flex: 1 1 auto; justify-content: center; }425}426@media (prefers-reduced-motion: reduce) {427 .fd-tyo-link::after, .fd-tyo-btn, .fd-tyo-btn svg { transition: none; }428}429`;430 431export function Demo() {432 return <HeroTypeOnly />;433}More Heroes. Same file contract.
Questions about Type Foundry Hero
Run npx shadcn@latest add https://freedesignmd.com/r/hero-type-only.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.
Typography hero section with no images: a giant specimen word whose letters swell in weight under the pointer. It is a free, MIT-licensed React heroes component from the freedesignmd library.
Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.
All evergreen browsers. Weight interpolates continuously on variable system fonts (SF Pro, Segoe UI Variable) and steps on static ones.
Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.


