Editorial Hero with Sliding Letters and Drawn Underline

A minimal, bright hero in the style of a magazine cover. A very large serif headline fills the width, each letter sliding up from behind a clip edge in sequence, and one word is set in red italics with a brush-like underline that draws itself, holds and redraws every few seconds. A masthead line sits above and a short paragraph, two links and a slowly rotating circular text badge sit below. The headline size is fluid, so it fills a phone as confidently as a desktop.

Code

import { useEffect, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";

/** Each line of the headline. `underline` marks the word that gets the drawn stroke; `italic` sets it in italics. */
const lines = [
  [{ text: "Read" }, { text: "less." }],
  [{ text: "Know" }, { text: "more.", underline: true, italic: true }],
];

const badgeText = "NEW ISSUE EVERY THURSDAY MORNING • ";

/** Seconds between one letter and the next. */
const LETTER_STAGGER = 0.028;

export default function EditorialHeroWithSlidingLettersAndDrawnUnderline() {
  const reduce = !!useReducedMotion();
  const ref = useRef<HTMLElement>(null);
  const [onScreen, setOnScreen] = useState(true);
  const circle = useId();

  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting), { threshold: 0.05 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-50";
  let letter = 0;

  return (
    <section ref={ref} data-paused={!onScreen} className="ehs-root overflow-hidden bg-orange-50 px-6 pb-14 pt-8 text-stone-900 sm:pb-16">
      <style>{`
        @keyframes editorial-hero-with-sliding-letters-and-drawn-underline-draw { 0% { stroke-dashoffset: 1; opacity: 0; } 1% { opacity: 1; } 11% { stroke-dashoffset: 0; } 86% { stroke-dashoffset: 0; } 96% { stroke-dashoffset: -1; opacity: 1; } 97%, 100% { stroke-dashoffset: -1; opacity: 0; } }
        @keyframes editorial-hero-with-sliding-letters-and-drawn-underline-spin { to { transform: rotate(360deg); } }
        @keyframes editorial-hero-with-sliding-letters-and-drawn-underline-nudge { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(3px, -3px); } }
        .ehs-draw { stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0; animation: editorial-hero-with-sliding-letters-and-drawn-underline-draw 6.5s cubic-bezier(0.6, 0, 0.2, 1) 0.75s infinite; }
        .ehs-spin { animation: editorial-hero-with-sliding-letters-and-drawn-underline-spin 16s linear infinite; transform-origin: 50% 50%; }
        .ehs-nudge { animation: editorial-hero-with-sliding-letters-and-drawn-underline-nudge 2.4s ease-in-out infinite; }
        .ehs-root[data-paused="true"] * { animation-play-state: paused !important; }
        @media (prefers-reduced-motion: reduce) { .ehs-draw { animation: none; stroke-dashoffset: 0; opacity: 1; } .ehs-spin, .ehs-nudge { animation: none; } }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <motion.div
          className="flex items-center justify-between gap-4 border-b border-stone-900 pb-4 text-sm"
          initial={reduce ? false : { opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ duration: 0.8 }}
        >
          <p className="font-serif text-lg font-semibold italic">The Slow Brief</p>
          <p className="hidden text-stone-700 sm:block">A weekly letter on science, money and cities</p>
          <p className="tabular-nums text-stone-700">Issue 148</p>
        </motion.div>

        <h1 className="mt-10 font-serif text-[clamp(3rem,13.5vw,11.5rem)] font-semibold leading-[0.92] tracking-tighter sm:mt-14">
          <span className="sr-only">{lines.map((l) => l.map((w) => w.text).join(" ")).join(" ")}</span>
          <span aria-hidden="true">
            {lines.map((words, li) => (
              <span key={li} className="block">
                {words.map((w, wi) => (
                  <span key={wi} className={`relative inline-block whitespace-nowrap ${wi < words.length - 1 ? "mr-[0.22em]" : ""} ${w.italic ? "italic text-red-600" : ""}`}>
                    {/* The clip box hides each letter until it slides up into place. */}
                    <span className="-my-[0.12em] inline-block overflow-hidden py-[0.12em] pr-[0.06em]">
                      {w.text.split("").map((ch, ci) => {
                        const delay = 0.1 + letter++ * LETTER_STAGGER;
                        return (
                          <motion.span
                            key={ci}
                            className="inline-block origin-bottom-left"
                            initial={reduce ? false : { y: "112%", rotate: 9 }}
                            animate={{ y: "0%", rotate: 0 }}
                            transition={{ type: "spring", stiffness: 150, damping: 19, delay }}
                          >
                            {ch}
                          </motion.span>
                        );
                      })}
                    </span>
                    {w.underline && (
                      <svg viewBox="0 0 400 40" preserveAspectRatio="none" fill="none" className="absolute -bottom-[0.08em] left-0 h-[0.28em] w-full overflow-visible sm:-bottom-[0.06em] sm:h-[0.2em]">
                        <path className="ehs-draw" pathLength={1} d="M4 22C70 8 150 6 214 15s120 10 182-3c-58 6-118 14-176 22" stroke="currentColor" strokeWidth="7" strokeLinecap="round" strokeLinejoin="round" />
                      </svg>
                    )}
                  </span>
                ))}
              </span>
            ))}
          </span>
        </h1>

        <motion.div
          className="mt-12 flex flex-col gap-8 sm:mt-16 sm:flex-row sm:items-end sm:justify-between"
          initial={reduce ? false : { opacity: 0, y: 24 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.7, delay: 0.7, ease: [0.2, 0.7, 0.2, 1] }}
        >
          <div className="max-w-md">
            <p className="text-lg leading-relaxed text-stone-700">
              One long read, three short ones and nothing else. Written by people who had the time to check, for people who do not have the time to scroll.
            </p>
            <div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-3">
              <a href="#" className={`inline-flex items-center justify-center bg-stone-900 px-6 py-3.5 text-sm font-semibold text-orange-50 transition hover:bg-red-600 ${ring}`}>
                Subscribe, it is free
              </a>
              <a href="#" className={`inline-flex min-h-[44px] items-center text-sm font-semibold underline decoration-stone-400 underline-offset-4 transition hover:decoration-stone-900 ${ring}`}>
                Read last week's issue
              </a>
            </div>
          </div>

          <a href="#" aria-label="Read the new issue" className={`group relative block h-32 w-32 flex-none self-end rounded-full sm:h-36 sm:w-36 ${ring}`}>
            <svg viewBox="0 0 200 200" aria-hidden="true" className="ehs-spin h-full w-full">
              <defs>
                <path id={circle} d="M100 100m-78 0a78 78 0 1 1 156 0a78 78 0 1 1 -156 0" />
              </defs>
              <text className="fill-stone-900 text-[17.5px] font-semibold tracking-[0.2em]">
                <textPath href={`#${circle}`}>{badgeText}</textPath>
              </text>
            </svg>
            <span className="absolute inset-0 m-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-600 text-orange-50 transition-transform group-hover:scale-110">
              <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="ehs-nudge h-6 w-6">
                <path d="M5 15L15 5M7 5h8v8" />
              </svg>
            </span>
          </a>
        </motion.div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/editorial-hero-with-sliding-letters-and-drawn-underline.json

When to use it

How to customize it

Accessibility

Questions

How do the letters slide in?

Each word is wrapped in a box with `overflow-hidden`. Every letter inside starts below the bottom edge of that box, tilted a few degrees, and a spring brings it up to the baseline. A running counter gives each letter a slightly later delay than the one before it, across all lines.

How does the underline draw itself?

The SVG path has `pathLength` set to 1, a dash array of 1 and a dash offset of 1, which hides the stroke completely. Animating the offset to 0 reveals it from start to end, and continuing to minus 1 wipes it away in the same direction.

How does the underline fit a word of any length?

The SVG is absolutely positioned under the word with `w-full` and `preserveAspectRatio="none"`, so the same path stretches to whatever width the word has. Its height is set in `em`, which keeps the stroke in proportion to the type at every screen size.

How is the circular text made?

The text follows a circle with an SVG `textPath`, and the whole SVG rotates with one CSS keyframe. Adjust the letter spacing if you change the sentence so it meets itself neatly.

Can I use a custom font?

Yes. Replace `font-serif` on the `h1` with your own font class. Very wide display faces may need a smaller middle value in the `clamp`.

Related: Big Headline Hero with Stats Row, Dark Hero with Spotlight Sweep and Line Reveal, Hero with Word Reveal Headline and Floating Cards.

More hero sections or all free UI components. Start for free to have a full site built around it.

More in Hero Sections

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap