Skip to content

Letters Quarterly Cover Hero

Magazine cover hero with a full-bleed photo that develops from an overexposed print and a masthead that sets letter by letter.

  • Heroes
  • Publishing & media
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Vertical caption uses writing-mode; motion is CSS only and stops under prefers-reduced-motion.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/hero-fullbleed-photo.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/heroes/hero-fullbleed-photo.tsx
1/**
2 * HeroFullbleedPhoto: a magazine-cover hero for a literary quarterly of
3 * private letters and family archives. A full-bleed photograph "develops" in
4 * from an overexposed sepia print, then drifts in a slow Ken Burns. The
5 * masthead is set letter by letter, the headline arrives line by line, a
6 * disciplined text block sits in the lower left, cover lines with page
7 * numbers in the lower right and a vertical mono caption runs up the edge.
8 *
9 * Props: src, alt, masthead, issue, kicker, titleLines, dek, availability,
10 * coverLines, caption, nav, primaryCta, secondaryCta.
11 * CSS vars: --fd-fbp-paper, --fd-fbp-ink, --fd-fbp-accent.
12 *
13 * Browser support: all evergreen browsers (writing-mode for the caption).
14 * Motion: CSS only. Print develop (700ms), staggered masthead letters and
15 * headline lines (700ms each), then a 32s Ken Burns drift.
16 * prefers-reduced-motion removes all of it: the photo and type are shown still.
17 * Accessibility: the photo has alt text, the masthead letters are aria-hidden
18 * with the name on the brand link, the mobile menu is a disclosure button with
19 * aria-expanded and closes on Escape. Text sits on a scrim that keeps
20 * contrast above 4.5:1.
21 */
22import { useEffect, useState } from "react";
23import type { CSSProperties, ReactNode } from "react";
24
25export interface HeroFullbleedPhotoProps {
26 src?: string;
27 alt?: string;
28 masthead?: string;
29 issue?: string;
30 kicker?: string;
31 titleLines?: ReactNode[];
32 dek?: ReactNode;
33 availability?: ReactNode;
34 coverLines?: { n: string; text: string; href: string }[];
35 caption?: string[];
36 nav?: { label: string; href: string }[];
37 primaryCta?: { label: string; href: string };
38 secondaryCta?: { label: string; href: string };
39}
40
41const DEFAULT_NAV = [
42 { label: "Current issue", href: "#issue" },
43 { label: "The archive", href: "#archive" },
44 { label: "Send us letters", href: "#submit" },
45 { label: "Stockists", href: "#stockists" },
46];
47
48const DEFAULT_LINES = [
49 { n: "p. 14", text: "Her recipe for panisse, in her hand, with two crossings-out", href: "#panisse" },
50 { n: "p. 52", text: "Who is the man in the third photograph? Readers wrote in", href: "#photograph" },
51 { n: "p. 96", text: "How to read cursive from 1929: a short, patient guide", href: "#cursive" },
52];
53
54const DEFAULT_CAPTION = ["43°17′47″N 5°22′49″E", "Rue d'Aubagne, Marseille", "Notebook 2, p. 41", "Ferrand family archive"];
55
56export function HeroFullbleedPhoto({
57 src = "/img/halcyon/journals.webp",
58 alt = "Open handwritten notebooks and old black and white family photographs spread across a rust and sand coloured table",
59 masthead = "Recto",
60 issue = "No. 11, Winter 2026",
61 kicker = "A quarterly of private letters",
62 titleLines = [
63 "Four notebooks,",
64 "one Marseille kitchen,",
65 <em key="e">1928 to 1931.</em>,
66 ],
67 dek = "Odette Ferrand wrote to her daughter every Sunday for three years. We transcribed all 212 pages, translated them, and then went looking for the house.",
68 availability = (
69 <>
70 Ships <b>Fri 20 Nov</b>, 148 pages, €18 an issue or €64 for four
71 </>
72 ),
73 coverLines = DEFAULT_LINES,
74 caption = DEFAULT_CAPTION,
75 nav = DEFAULT_NAV,
76 primaryCta = { label: "Subscribe, four issues", href: "#subscribe" },
77 secondaryCta = { label: "Read the first letter", href: "#letter" },
78}: HeroFullbleedPhotoProps) {
79 const [open, setOpen] = useState(false);
80
81 useEffect(() => {
82 if (!open) return;
83 const onKey = (e: KeyboardEvent) => {
84 if (e.key === "Escape") setOpen(false);
85 };
86 window.addEventListener("keydown", onKey);
87 return () => window.removeEventListener("keydown", onKey);
88 }, [open]);
89
90 return (
91 <section className="fd-fbp" aria-label={`${masthead}, ${issue}`}>
92 <style>{css}</style>
93 <div className="fd-fbp-photo">
94 <img className="fd-fbp-img" src={src} alt={alt} width={1024} height={1024} />
95 <div className="fd-fbp-scrim" aria-hidden="true" />
96 </div>
97
98 <header className="fd-fbp-nav">
99 <a className="fd-fbp-brand" href="#top" aria-label={`${masthead}, home`}>
100 <svg className="fd-fbp-stamp" viewBox="0 0 40 40" width="34" height="34" aria-hidden="true">
101 <circle cx="20" cy="20" r="18" fill="none" stroke="currentColor" strokeWidth="1.2" />
102 <circle cx="20" cy="20" r="13.5" fill="none" stroke="currentColor" strokeWidth="0.8" strokeDasharray="1.5 2" />
103 <path d="M6 17 H34 M6 23 H34" stroke="currentColor" strokeWidth="0.8" />
104 <text x="20" y="21.6" textAnchor="middle" className="fd-fbp-stamp-t">R</text>
105 </svg>
106 <span className="fd-fbp-issue">{issue}</span>
107 </a>
108 <nav className={`fd-fbp-links${open ? " is-open" : ""}`} id="fd-fbp-menu" aria-label="Primary">
109 {nav.map((l) => (
110 <a key={l.href} className="fd-fbp-link" href={l.href} onClick={() => setOpen(false)}>
111 {l.label}
112 </a>
113 ))}
114 <a className="fd-fbp-reserve" href={primaryCta.href} onClick={() => setOpen(false)}>Subscribe</a>
115 </nav>
116 <button
117 type="button"
118 className="fd-fbp-burger"
119 aria-expanded={open}
120 aria-controls="fd-fbp-menu"
121 onClick={() => setOpen((o) => !o)}
122 >
123 <span className="fd-fbp-burger-lines" aria-hidden="true" />
124 {open ? "Close" : "Menu"}
125 </button>
126 </header>
127
128 <p className="fd-fbp-masthead" aria-hidden="true">
129 {Array.from(masthead).map((ch, i) => (
130 <span key={i} className="fd-fbp-ml" style={{ "--fd-fbp-i": i } as CSSProperties}>{ch}</span>
131 ))}
132 </p>
133
134 <div className="fd-fbp-block">
135 <p className="fd-fbp-kicker">
136 <span className="fd-fbp-rule" aria-hidden="true" />
137 {kicker}
138 </p>
139 <h1 className="fd-fbp-title">
140 {titleLines.map((line, i) => (
141 <span key={i} className="fd-fbp-tl" style={{ "--fd-fbp-i": i } as CSSProperties}>
142 <span className="fd-fbp-tl-in">{line}</span>
143 </span>
144 ))}
145 </h1>
146 <p className="fd-fbp-dek">{dek}</p>
147 <div className="fd-fbp-ctas">
148 <a className="fd-fbp-btn" href={primaryCta.href}>
149 {primaryCta.label}
150 <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>
151 </a>
152 <a className="fd-fbp-text" href={secondaryCta.href}>{secondaryCta.label}</a>
153 </div>
154 <p className="fd-fbp-avail">
155 <span className="fd-fbp-live" aria-hidden="true" />
156 <span>{availability}</span>
157 </p>
158 </div>
159
160 <ol className="fd-fbp-lines" aria-label="In this issue">
161 {coverLines.map((l) => (
162 <li key={l.n} className="fd-fbp-line">
163 <a href={l.href} className="fd-fbp-line-a">
164 <span className="fd-fbp-line-n">{l.n}</span>
165 <span className="fd-fbp-line-t">{l.text}</span>
166 </a>
167 </li>
168 ))}
169 </ol>
170
171 <p className="fd-fbp-caption">
172 {caption.map((c, i) => (
173 <span key={i} className="fd-fbp-cap">{c}</span>
174 ))}
175 </p>
176 </section>
177 );
178}
179
180const css = `
181.fd-fbp, .fd-fbp *, .fd-fbp *::before, .fd-fbp *::after { box-sizing: border-box; }
182.fd-fbp :where(h1, p, a, ol, li, em, b, span, div, nav, header, button, img, svg) {
183 margin: 0; padding: 0; border: 0; background: none; font: inherit; color: inherit;
184 letter-spacing: inherit; text-decoration: none; list-style: none;
185}
186.fd-fbp {
187 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
188 --fd-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
189 --fd-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
190 --fd-dur-1: 120ms; --fd-dur-2: 240ms; --fd-dur-3: 420ms; --fd-dur-4: 700ms;
191 --fd-fbp-paper: #F4ECDD;
192 --fd-fbp-ink: #1A1410;
193 --fd-fbp-accent: #B9CAF0;
194 --fd-fbp-serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
195 --fd-fbp-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
196 --fd-fbp-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
197 position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 360px) 56px;
198 grid-template-rows: auto auto 1fr auto; column-gap: 40px;
199 min-height: 880px; margin: 0; padding: 0 0 48px 48px; overflow: hidden; isolation: isolate;
200 background: var(--fd-fbp-ink); color: var(--fd-fbp-paper); font: 400 15px/1.45 var(--fd-fbp-sans);
201}
202
203/* photo */
204.fd-fbp-photo { position: absolute; inset: 0; z-index: -1; overflow: hidden; background: #3A2A20; }
205.fd-fbp-img {
206 display: block; width: 100%; height: 100%; object-fit: cover; object-position: 50% 62%;
207 transform-origin: 30% 70%; transform: scale(1.04);
208 animation:
209 fd-fbp-develop var(--fd-dur-4) var(--fd-ease-out) 0.1s both,
210 fd-fbp-burns 32s var(--fd-ease-in-out) 1.2s infinite alternate;
211}
212@keyframes fd-fbp-develop {
213 from { filter: sepia(1) brightness(1.9) contrast(0.45) blur(3px); }
214 to { filter: none; }
215}
216@keyframes fd-fbp-burns {
217 from { transform: scale(1.04) translate3d(0, 0, 0); }
218 to { transform: scale(1.16) translate3d(1.4%, -1.6%, 0); }
219}
220.fd-fbp-scrim {
221 position: absolute; inset: 0;
222 background:
223 linear-gradient(180deg, rgba(20,14,10,0.78) 0%, rgba(20,14,10,0.35) 30%, rgba(20,14,10,0.2) 45%),
224 linear-gradient(0deg, rgba(20,14,10,0.92) 0%, rgba(20,14,10,0.72) 34%, rgba(20,14,10,0) 66%),
225 linear-gradient(90deg, rgba(20,14,10,0.7) 0%, rgba(20,14,10,0.35) 40%, rgba(20,14,10,0) 60%),
226 radial-gradient(60% 50% at 88% 100%, rgba(20,14,10,0.75) 0%, rgba(20,14,10,0) 100%);
227}
228
229/* nav */
230.fd-fbp-nav {
231 grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 24px;
232 padding: 22px 48px 0 0; position: relative; z-index: 4;
233}
234.fd-fbp-brand { display: inline-flex; align-items: center; gap: 14px; color: var(--fd-fbp-paper); }
235.fd-fbp-stamp { display: block; transform: rotate(-12deg); }
236.fd-fbp-stamp-t { font: italic 400 15px/1 var(--fd-fbp-serif); fill: currentColor; dominant-baseline: middle; }
237.fd-fbp-issue { font: 400 11px/1 var(--fd-fbp-mono); letter-spacing: 0.08em; text-transform: uppercase; padding-left: 14px; border-left: 1px solid rgba(244,236,221,0.5); }
238.fd-fbp-links { display: flex; align-items: center; gap: 30px; }
239.fd-fbp-link { position: relative; font: 400 15px/1.2 var(--fd-fbp-sans); color: var(--fd-fbp-paper); }
240.fd-fbp-link::after { content: ""; position: absolute; left: 0; right: 0; bottom: -5px; height: 1px; background: currentColor; transform: scaleX(0); transform-origin: left; transition: transform var(--fd-dur-2) var(--fd-ease-out); }
241.fd-fbp-link:hover::after { transform: scaleX(1); }
242.fd-fbp-reserve { font: 500 14px/1 var(--fd-fbp-sans); padding: 11px 18px; border-radius: 9999px; border: 1px solid rgba(244,236,221,0.7); color: var(--fd-fbp-paper); transition: background var(--fd-dur-1), color var(--fd-dur-1); }
243.fd-fbp-reserve:hover { background: var(--fd-fbp-paper); color: var(--fd-fbp-ink); }
244.fd-fbp-burger { display: none; appearance: none; cursor: pointer; align-items: center; gap: 10px; color: var(--fd-fbp-paper); font: 500 13px/1 var(--fd-fbp-mono); letter-spacing: 0.06em; text-transform: uppercase; padding: 8px 0; background: none; border: 0; }
245.fd-fbp-burger-lines { position: relative; display: block; width: 20px; height: 7px; border-top: 1px solid currentColor; border-bottom: 1px solid currentColor; }
246
247/* masthead, set letter by letter */
248.fd-fbp-masthead {
249 grid-column: 1 / 3; grid-row: 2; display: flex; margin: 4px 0 0 -0.05em;
250 font: italic 400 clamp(6rem, 19vw, 18rem)/0.84 var(--fd-fbp-serif);
251 letter-spacing: -0.045em; color: var(--fd-fbp-paper);
252}
253.fd-fbp-ml {
254 display: inline-block; padding-right: 0.02em;
255 animation: fd-fbp-set var(--fd-dur-4) var(--fd-ease-out) both;
256 animation-delay: calc(0.35s + var(--fd-fbp-i) * 90ms);
257}
258@keyframes fd-fbp-set {
259 from { opacity: 0; transform: translateY(0.18em) rotate(-4deg); clip-path: inset(0 0 100% 0); }
260 to { opacity: 1; transform: none; clip-path: inset(-20% -20% -20% -20%); }
261}
262
263/* block */
264.fd-fbp-block { grid-column: 1; grid-row: 4; align-self: end; max-width: 620px; }
265.fd-fbp-kicker { display: flex; align-items: center; gap: 12px; font: 400 12px/1.2 var(--fd-fbp-mono); letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 20px; }
266.fd-fbp-rule { width: 32px; height: 1px; background: var(--fd-fbp-accent); display: block; }
267.fd-fbp-title { display: flex; flex-direction: column; font: 400 clamp(2.375rem, 4vw, 3.625rem)/1.02 var(--fd-fbp-serif); white-space: nowrap; letter-spacing: -0.025em; color: var(--fd-fbp-paper); }
268.fd-fbp-tl { display: block; overflow: hidden; padding-bottom: 0.06em; margin-bottom: -0.06em; }
269.fd-fbp-tl-in { display: block; animation: fd-fbp-line var(--fd-dur-4) var(--fd-ease-out) both; animation-delay: calc(0.8s + var(--fd-fbp-i) * 110ms); }
270@keyframes fd-fbp-line { from { transform: translateY(105%); } to { transform: none; } }
271.fd-fbp-title em { font-style: italic; color: var(--fd-fbp-accent); }
272.fd-fbp-dek { margin-top: 20px; max-width: 46ch; font: 400 16px/1.5 var(--fd-fbp-sans); color: rgba(244,236,221,0.88); }
273.fd-fbp-ctas { display: flex; align-items: center; gap: 24px; margin-top: 28px; flex-wrap: wrap; }
274.fd-fbp-btn {
275 display: inline-flex; align-items: center; gap: 12px; height: 50px; padding: 0 24px; border-radius: 9999px;
276 background: var(--fd-fbp-paper); color: var(--fd-fbp-ink); font: 600 15px/1 var(--fd-fbp-sans);
277 transition: background var(--fd-dur-1), transform var(--fd-dur-2) var(--fd-spring);
278}
279.fd-fbp-btn svg { display: block; transition: transform var(--fd-dur-2) var(--fd-ease-out); }
280.fd-fbp-btn:hover { background: #FFFFFF; }
281.fd-fbp-btn:hover svg { transform: translateX(3px); }
282.fd-fbp-btn:active { transform: scale(0.96); transition-duration: var(--fd-dur-1); }
283.fd-fbp-text { font: 500 15px/1.2 var(--fd-fbp-sans); color: var(--fd-fbp-paper); border-bottom: 1px solid rgba(244,236,221,0.5); padding-bottom: 3px; transition: border-color var(--fd-dur-1); }
284.fd-fbp-text:hover { border-color: var(--fd-fbp-paper); }
285.fd-fbp-avail { display: flex; align-items: baseline; gap: 10px; margin-top: 26px; padding-top: 14px; border-top: 1px solid rgba(244,236,221,0.28); font: 400 12px/1.5 var(--fd-fbp-mono); color: rgba(244,236,221,0.82); }
286.fd-fbp-avail b { font-weight: 700; color: var(--fd-fbp-paper); }
287.fd-fbp-live { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--fd-fbp-accent); display: block; transform: translateY(-1px); }
288
289/* cover lines */
290.fd-fbp-lines { grid-column: 2; grid-row: 4; align-self: end; display: flex; flex-direction: column; border-top: 1px solid rgba(244,236,221,0.5); }
291.fd-fbp-line { border-bottom: 1px solid rgba(244,236,221,0.28); }
292.fd-fbp-line-a { display: grid; grid-template-columns: 52px 1fr; gap: 12px; padding: 14px 0; color: var(--fd-fbp-paper); }
293.fd-fbp-line-n { font: 400 11px/1.6 var(--fd-fbp-mono); color: var(--fd-fbp-accent); letter-spacing: 0.04em; }
294.fd-fbp-line-t { font: 400 17px/1.3 var(--fd-fbp-serif); letter-spacing: -0.005em; transition: transform var(--fd-dur-2) var(--fd-ease-out); }
295.fd-fbp-line-a:hover .fd-fbp-line-t { transform: translateX(4px); }
296
297/* vertical caption */
298.fd-fbp-caption {
299 grid-column: 3; grid-row: 2 / 5; justify-self: center; align-self: end;
300 writing-mode: vertical-rl; display: flex; gap: 22px; padding: 0;
301 font: 400 11px/1 var(--fd-fbp-mono); letter-spacing: 0.12em; text-transform: uppercase; color: rgba(244,236,221,0.9);
302}
303.fd-fbp-cap { display: block; white-space: nowrap; }
304.fd-fbp-cap + .fd-fbp-cap { padding-top: 22px; border-top: 1px solid rgba(244,236,221,0.45); }
305
306.fd-fbp :focus-visible { outline: 2px solid var(--fd-fbp-paper); outline-offset: 3px; border-radius: 2px; }
307
308@media (max-width: 1080px) {
309 .fd-fbp { grid-template-columns: minmax(0, 1fr) 40px; }
310 .fd-fbp-lines { display: none; }
311 .fd-fbp-caption { grid-column: 2; }
312}
313@media (max-width: 760px) {
314 .fd-fbp { grid-template-columns: minmax(0, 1fr) 28px; column-gap: 12px; padding: 0 0 28px 20px; min-height: 844px; }
315 .fd-fbp-nav { padding: 16px 20px 0 0; }
316 .fd-fbp-burger { display: inline-flex; }
317 .fd-fbp-links {
318 display: none; position: absolute; top: 100%; left: -20px; right: 0; margin-top: 12px; z-index: 5;
319 flex-direction: column; align-items: stretch; gap: 0; padding: 8px 20px 20px;
320 background: var(--fd-fbp-paper); color: var(--fd-fbp-ink);
321 }
322 .fd-fbp-links.is-open { display: flex; }
323 .fd-fbp-link { color: var(--fd-fbp-ink); font: 400 26px/1.1 var(--fd-fbp-serif); padding: 14px 0; border-bottom: 1px solid rgba(26,20,16,0.15); }
324 .fd-fbp-link::after { display: none; }
325 .fd-fbp-reserve { margin-top: 16px; text-align: center; padding: 16px; background: var(--fd-fbp-ink); color: var(--fd-fbp-paper); border-color: var(--fd-fbp-ink); }
326 .fd-fbp-masthead { grid-column: 1 / -1; font-size: 38vw; margin-top: 14px; }
327 .fd-fbp-caption { grid-row: 3 / 5; }
328 .fd-fbp-img { object-position: 40% 60%; }
329 .fd-fbp-title { font-size: 2.125rem; }
330 .fd-fbp-dek { font-size: 15px; }
331 .fd-fbp-ctas { gap: 18px; }
332 .fd-fbp-caption { font-size: 10px; gap: 16px; }
333 .fd-fbp-cap + .fd-fbp-cap { padding-top: 16px; }
334 .fd-fbp-cap:nth-child(n + 3) { display: none; }
335 .fd-fbp-scrim {
336 background:
337 linear-gradient(180deg, rgba(20,14,10,0.78) 0%, rgba(20,14,10,0.3) 34%),
338 linear-gradient(0deg, rgba(20,14,10,0.96) 0%, rgba(20,14,10,0.86) 50%, rgba(20,14,10,0.3) 68%, rgba(20,14,10,0) 78%);
339 }
340}
341@media (prefers-reduced-motion: reduce) {
342 .fd-fbp-img { animation: none; transform: scale(1.04); filter: none; }
343 .fd-fbp-ml, .fd-fbp-tl-in { animation: none; }
344 .fd-fbp-link::after, .fd-fbp-btn, .fd-fbp-btn svg, .fd-fbp-line-t { transition: none; }
345}
346`;
347
348export function Demo() {
349 return <HeroFullbleedPhoto />;
350}

More Heroes. Same file contract.

Ledger Card Hero, heroes preview: Fintech hero section with a CSS 3D metal card that tilts to the cursor, then flips and docks on scroll as…

Ledger Card Hero

Fintech hero section with a CSS 3D metal card that tilts to the cursor, then flips and docks on scroll as stats count up.

Agent Terminal Hero, heroes preview: AI hero section with a terminal that types a prompt and streams an agent's plan while a dashboard assembles…

Agent Terminal Hero

AI hero section with a terminal that types a prompt and streams an agent's plan while a dashboard assembles beside it.

Maison Editorial Hero, heroes preview: Editorial fashion hero section: a giant serif masthead over three campaign photos, revealed by clip-path wipes

Maison Editorial Hero

Editorial fashion hero section: a giant serif masthead over three campaign photos, revealed by clip-path wipes.

Questions about Letters Quarterly Cover Hero

Run npx shadcn@latest add https://freedesignmd.com/r/hero-fullbleed-photo.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.

Magazine cover hero with a full-bleed photo that develops from an overexposed print and a masthead that sets letter by letter. 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. Vertical caption uses writing-mode; motion is CSS only and stops under prefers-reduced-motion.

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.