Block
Big headline
A big, heavy headline whose words rise one by one as it comes into view. Only the words marked with a star are in full ink.
- #arc
- #block
- #headline
- #heading
- #title
- #typography
- #animation
- #blog
- #marketing
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/big-headline.jsonFiles
components/arc/blocks/big-headline/big-headline.module.csscomponents/arc/blocks/big-headline/big-headline.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownbig-headline.module.cssbig-headline.module.cssbig-headline.module.csscss
/* Key words take --headline-ink, the rest --headline-muted. A surrounding band (StoryBand) recolours them through its inherited
--band-ink and --band-muted; outside a band they are the page's foreground and secondary text. */
.headline {
--headline-ink: var(--band-ink, var(--foreground));
--headline-muted: var(--band-muted, var(--text-secondary));
margin: 0;
color: var(--headline-ink);
font-family: var(--font-display);
font-size: clamp(36px, 8vw, 112px);
font-weight: 900;
line-height: 1.04;
letter-spacing: -.048em;
text-wrap: balance;
word-break: keep-all;
}
.headline[data-size="lg"] { font-size: clamp(32px, 5.6vw, 76px); }
.line { display: block; }
/* The clip hides the rise; padding with matching negative margins leaves room for descenders without changing layout. */
.clip { display: inline-block; margin: -.06em -.02em -.16em; padding: .06em .02em .16em; overflow: hidden; vertical-align: top; color: var(--headline-muted); }
.clip[data-key="true"] { color: var(--headline-ink); }
.word { display: inline-block; transform: translateY(112%); transition: transform calc(var(--duration-considered) * 1.5) var(--ease-enter) var(--delay, 0ms); }
.headline[data-in="true"] .word { transform: none; }
/* Server rendered words wait below the clip. If scripts have not taken over after 2.4s, show them anyway; the class goes away on hydration. */
.pending .word { animation: settle var(--duration-considered) var(--ease-enter) 2.4s forwards; }
@keyframes settle { to { transform: none; } }
/* Without scripts nothing would ever lift the words, so they start in place. */
@media (scripting: none) {
.word { transform: none; transition: none; }
.pending .word { animation: none; }
}
/* Reduced motion (the OS setting or the site's own) shows the words from the first paint, before the hook can say so. */
@media (prefers-reduced-motion: reduce) {
.word { transform: none; transition: none; }
.pending .word { animation: none; }
}
:global(:root[data-motion="reduce"]) .word { transform: none; transition: none; }
:global(:root[data-motion="reduce"]) .pending .word { animation: none; }
/* On paper there is no scrolling to bring a headline into view: every word is printed in place. */
@media print {
.word { transform: none; transition: none; }
.pending .word { animation: none; }
}
big-headline.test.tsbig-headline.test.tsbig-headline.test.tsts
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { BandInViewContext } from "../../lib/band-in-view";
import { BigHeadline, literalHeadline, parseHeadline } from "./big-headline";
describe("parseHeadline", () => {
it("splits lines on | and marks *word and *word* as key words without their stars", () => {
expect(parseHeadline("한국어 웹앱을|*디자인된* 글로")).toEqual([
{ words: [{ text: "한국어", key: false }, { text: "웹앱을", key: false }] },
{ words: [{ text: "디자인된", key: true }, { text: "글로", key: false }] },
]);
expect(parseHeadline("*진하게 옅게")[0]!.words[0]).toEqual({ text: "진하게", key: true });
});
it("drops empty lines and extra spaces", () => {
expect(parseHeadline("하나|| 둘 셋 |")).toEqual([
{ words: [{ text: "하나", key: false }] },
{ words: [{ text: "둘", key: false }, { text: "셋", key: false }] },
]);
expect(parseHeadline("")).toEqual([]);
expect(parseHeadline(" | ")).toEqual([]);
});
it("drops a lone star instead of rendering an empty key word", () => {
expect(parseHeadline("앞 * 뒤")).toEqual([{ words: [{ text: "앞", key: false }, { text: "뒤", key: false }] }]);
expect(parseHeadline("**")).toEqual([]);
});
});
describe("literalHeadline", () => {
it("keeps | and * as written: one line, every word in full ink", () => {
expect(literalHeadline("A|B *별표* 그대로")).toEqual([
{ words: [{ text: "A|B", key: true }, { text: "*별표*", key: true }, { text: "그대로", key: true }] },
]);
expect(literalHeadline(" ")).toEqual([]);
});
});
describe("BigHeadline server render", () => {
const render = (props: Parameters<typeof BigHeadline>[0], band: boolean | null = null) =>
renderToStaticMarkup(createElement(BandInViewContext.Provider, { value: band }, createElement(BigHeadline, props)));
it("puts the Korean text in the markup, in an h2 by default and an h1 on request", () => {
const html = render({ lines: "한국어 웹앱을|*디자인된* 글로" });
expect(html).toMatch(/^<h2[\s>]/);
for (const word of ["한국어", "웹앱을", "디자인된", "글로"]) expect(html).toContain(word);
expect(html).not.toContain("*");
expect(html).toContain('aria-label="한국어 웹앱을 디자인된 글로"');
expect(render({ lines: "제목", level: 1, id: "top" })).toMatch(/^<h1[^>]*id="top"/);
});
it("starts out of view on the server, and follows the band, then the explicit prop", () => {
expect(render({ lines: "제목" })).toContain('data-in="false"');
expect(render({ lines: "제목" }, true)).toContain('data-in="true"');
expect(render({ lines: "제목", inView: false }, true)).toContain('data-in="false"');
expect(render({ lines: "제목", inView: true })).toContain('data-in="true"');
});
it("renders a literal title as written, with no line breaks or key-word markers", () => {
const html = render({ lines: "1|2 *별* 제목", literal: true, level: 1 });
expect(html).toMatch(/^<h1[\s>]/);
expect(html).toContain('aria-label="1|2 *별* 제목"');
expect(html).toContain(">1|2</span>");
expect(html).toContain(">*별*</span>");
expect(html.match(/aria-hidden="true"/g)).toHaveLength(1);
});
it("marks the size", () => {
expect(render({ lines: "제목" })).toContain('data-size="xl"');
expect(render({ lines: "제목", size: "lg" })).toContain('data-size="lg"');
});
it("keeps the words visible without scripts and with reduced motion (the hidden start is CSS only)", () => {
const css = readFileSync(new URL("./big-headline.module.css", import.meta.url), "utf8");
expect(css).toMatch(/@media \(scripting: none\)/);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
expect(css).toMatch(/:root\[data-motion="reduce"\]/);
expect(css).not.toMatch(/#[0-9a-f]{3,6}\b/i);
});
it("puts every word in place in print, whether or not the headline was seen", () => {
const css = readFileSync(new URL("./big-headline.module.css", import.meta.url), "utf8");
const print = /@media print \{([\s\S]*?)\n\}/.exec(css)?.[1] ?? "";
expect(print).toContain(".word { transform: none; transition: none; }");
expect(print).toContain(".pending .word { animation: none; }");
});
});
big-headline.tsxbig-headline.tsxbig-headline.tsxtsx
"use client";
import { Fragment, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
import { useInView } from "motion/react";
import { useBandInView } from "../../lib/band-in-view";
import { motionTokens } from "../../lib/motion-tokens";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import styles from "./big-headline.module.css";
export type BigHeadlineSize = "xl" | "lg";
export interface BigHeadlineProps {
/** Lines split by `|`. A word written `*word` (or `*word*`) is a key word in full ink; the others are the softer secondary color. */
lines: string;
/** `xl` for a page's opening statement, `lg` for a section. */
size?: BigHeadlineSize;
/** `h2` by default; `1` for the one headline that names the page. */
level?: 1 | 2;
id?: string;
/**
* Treats `lines` as one line of plain text, such as a post title: `|` and `*` are kept as written and every word is in full ink.
* Use it for text the reader did not write in the headline syntax.
*/
literal?: boolean;
/** Forces the risen (`true`) or waiting (`false`) state. Without it, the surrounding band decides, or the headline watches the viewport itself. */
inView?: boolean;
}
export interface HeadlineWord { text: string; key: boolean }
/** Splits `lines` into lines of words. Empty lines and a lone `*` are dropped; a key word loses its leading star and one closing star. */
export function parseHeadline(lines: string): { words: HeadlineWord[] }[] {
return lines
.split("|")
.map((line) => ({
words: line
.split(/\s+/)
.filter(Boolean)
.map((raw) => (raw.startsWith("*") ? { text: raw.slice(1).replace(/\*$/, ""), key: true } : { text: raw, key: false }))
.filter((word) => word.text.length > 0),
}))
.filter((line) => line.words.length > 0);
}
/** One line of plain text, split only on spaces: `|` and `*` stay as written, and every word is a key word (full ink). */
export function literalHeadline(text: string): { words: HeadlineWord[] }[] {
const words = text.split(/\s+/).filter(Boolean).map((word) => ({ text: word, key: true }));
return words.length > 0 ? [{ words }] : [];
}
/** Each word rises this long after the one before it, after a short lead-in. */
const STEP_MS = 90;
const LEAD_MS = motionTokens.delay.micro * 1000;
/** Matches the delay of the stylesheet fallback: scripts that arrive later than this keep the already shown words still. */
const FALLBACK_MS = 2400;
const lateBoot = typeof window !== "undefined" && performance.now() > FALLBACK_MS;
const subscribeHydration = () => () => {};
/**
* A big, heavy headline in lines whose words rise out of a clip one after another when it comes into view. Key words are full ink,
* the rest are the secondary color. The text is in the server markup; without scripts, with reduced motion, or when scripts are slow,
* the words are simply shown.
*/
export function BigHeadline({ lines, size = "xl", level = 2, id, literal = false, inView }: BigHeadlineProps) {
const ref = useRef<HTMLHeadingElement>(null);
const band = useBandInView();
const seen = useInView(ref, { once: true, amount: 0.25 });
const reduced = useReducedMotion();
const hydrated = useSyncExternalStore(subscribeHydration, () => true, () => false);
const [serverRendered] = useState(!hydrated);
const late = hydrated && serverRendered && lateBoot;
const shown = (inView ?? band ?? seen) || reduced || late;
const parsed = literal ? literalHeadline(lines) : parseHeadline(lines);
const label = parsed.map((line) => line.words.map((word) => word.text).join(" ")).join(" ");
const Tag = level === 1 ? "h1" : "h2";
let index = 0;
return (
<Tag
ref={ref}
id={id}
className={`${styles.headline} ${hydrated ? "" : styles.pending}`}
data-size={size}
data-in={shown ? "true" : "false"}
aria-label={label}
>
{parsed.map((line, li) => (
<Fragment key={li}>
{li > 0 ? " " : null}
<span className={styles.line} aria-hidden="true">
{line.words.map((word, wi) => {
const delay = LEAD_MS + STEP_MS * index++;
return (
<Fragment key={wi}>
{wi > 0 ? " " : null}
<span className={styles.clip} data-key={word.key ? "true" : undefined}>
<span className={styles.word} style={{ "--delay": `${delay}ms` } as CSSProperties}>{word.text}</span>
</span>
</Fragment>
);
})}
</span>
</Fragment>
))}
</Tag>
);
}
export default BigHeadline;
Used by
- Post heroBlock
The opening band of a post. It gathers the way back, tag pills, a big heavy title, the summary, the dates and the translation and Markdown links, with an optional illustration beside them.