Wordmark-to-Initials Crossfade

  • CSS
  • Scroll-Driven Animations
  • Typography

CSS Scroll-Driven Animations

Wordmark-to-Initials Crossfade
No glyph morphing.

CSS only animation-timeline: scroll()
Scroll this page. Nothing happens at first: the lockup above only starts shrinking from "jon humphrey" to "jh" once it becomes stuck to the top, and the cursor tags along for the ride: no JavaScript, no width measurement, no glyph morphing.

Where This Came From

Anthropic's own site header does something similar: the wordmark logo collapses into just the icon mark as you scroll. Under the hood it's a Lottie animation: the letterforms were converted to vector paths in After Effects and hand-keyframed to morph into the icon shape, then scrubbed by scroll position via Webflow's interaction system.

That's a genuine shape morph, and it's the reason it needs vector paths rather than live text: browsers have no way to interpolate between two unrelated glyph outlines. The moment you need that morph, the text has to become artwork.

The Approximation

This demo swaps the morph for a crossfade between two real text nodes, stacked in the same grid cell and cross-faded with opacity. It's a different technique producing a similar impression (a substitution, not a morph), but it keeps the name as live, selectable text throughout.

@supports (animation-timeline: scroll()) {
  .wordmark__text {
    animation: wordmark-shrink linear both;
    animation-timeline: --wordmark-stuck;
    animation-range: exit 0% exit 100%;
  }

  .wordmark__full {
    animation: wordmark-fade-out linear both;
    animation-timeline: --wordmark-stuck;
    animation-range: exit 0% exit 100%;
  }

  .wordmark__short {
    animation: wordmark-fade-in linear both;
    animation-timeline: --wordmark-stuck;
    animation-range: exit 0% exit 100%;
  }
}

Triggering on Stuck, Not on Scroll

A scroll(root) timeline only knows raw document scroll position, and a view() timeline on .wordmark-demo itself can't tell you when it becomes stuck either: once pinned, a sticky element never leaves the viewport, so its own view-progress just plateaus at "fully visible" and can't distinguish that moment from any other.

Instead, a separate, invisible sentinel sits right before .wordmark-demo in the markup, sharing its natural, pre-sticky top edge. The sentinel gets its own named view-timeline, shifted by view-timeline-inset to line up with the same offset .wordmark-demo sticks to:

.wordmark-demo__sentinel {
  position: absolute;
  inset-block-start: 0;
  block-size: 240px;
}

@supports (animation-timeline: scroll()) {
  .reading-page {
    timeline-scope: --wordmark-stuck;
  }

  .wordmark-demo__sentinel {
    view-timeline-name: --wordmark-stuck;
    view-timeline-inset: var(--safe-top-padding) 0;
  }
}

The sentinel's own height, not .wordmark-demo's, is what sets the animation's scroll distance: its "exit" phase runs from its top edge crossing the inset line (exactly when .wordmark-demo becomes stuck, since the two share that top edge) to its bottom edge crossing the same line, 240px of scroll later. Before that crossing starts, animation-fill-mode: both holds the animation at its first keyframe, so nothing moves no matter how far you scroll towards it.

One easy trap: the sentinel can't simply wrap .wordmark-demo. A sticky element's containing block for stickiness purposes is whatever ancestor holds it, so wrapping it in a short wrapper shrinks the space it's allowed to stick within, un-sticking it again almost immediately. The sentinel has to be a sibling instead, with timeline-scope declared on their nearest shared ancestor so the named timeline reaches across to .wordmark-demo's descendants.

The Cursor Needs No Measurement

.wordmark__text is a flex sibling of .wordmark__cursor inside .wordmark. As .wordmark__text's inline-size animates from 12ch down to 2ch, the flex layout repositions the cursor automatically: there's no JavaScript reading getBoundingClientRect(), no resize observer. The browser's own layout engine is the thing doing the "measuring."

Using ch units on a monospace font means the two endpoint widths are just the character counts of the two strings: 12ch for "jon humphrey", 2ch for "jh".

The @supports Wrapper

Wrapping the shrink and crossfade in @supports (animation-timeline: scroll()) means browsers without support fall back to the base, non-animated styles: "jh" at 2ch, with the cursor resting immediately after it: the compact end state, not the expansive one, since nothing is available to animate it into view.

Delaying the Crossfade

The shrink and the crossfade share one animation-range, but each @keyframes block adds an intermediate stop at 80%: .wordmark__text reaches 2ch by then and holds, while .wordmark__full/.wordmark__short hold their starting opacity until that same 80% mark before crossfading over the remaining stretch. The container finishes shrinking first, clipping "jon humphrey" down to "jo" as it narrows, and only once just two characters' width remains does "jo" fade into "jh".

@keyframes wordmark-shrink {
  from { inline-size: 12ch; }
  80%  { inline-size: 2ch; }
  to   { inline-size: 2ch; }
}

@keyframes wordmark-fade-out {
  from, 80% { opacity: 1; }
  to        { opacity: 0; }
}

Reduced Motion

prefers-reduced-motion: reduce disables all three animations, which falls back to the same base styles as the @supports gap above: "jh" stays put, statically, and the cursor stops blinking rather than continuing to flicker.

Accessibility

The two text states and the cursor are decorative duplicates of the same information, so they carry aria-hidden="true". The outer .wordmark element uses aria-labelledby pointing at .wordmark__full rather than a duplicated aria-label string, so the accessible name is sourced from a single place and can't drift out of sync with the visible markup. Referenced elements are used for name computation regardless of their own aria-hidden state, so this works even though the span it points to is hidden from the accessibility tree in its own right.

Scroll to see it collapse; scroll back up to see it return.