Wordmark-to-Initials Crossfade
CSS Scroll-Driven Animations
Wordmark-to-Initials Crossfade
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.