Skip to content
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

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/big-headline.json

big-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.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.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