BLUR TO FOCUS
INTERACTION_031CSS
Blur To Focus Reveal
Copy resolves from a soft blur into perfect focus as it enters view.
Text / LightView
Characters type in behind a blinking caret, then hold. Empty with a visible caret before the trigger; revealing characters at roughly 28ms each, then holding the caret once the line enters view.
/* Typewriter Text — Characters type in behind a blinking caret, then hold. */
.typewriter-text {
--dur: 1.6s;
--ease: steps(28, end);
--stagger: 60ms;
will-change: transform, opacity;
}
.typewriter-text > * {
opacity: 0;
transform: translateY(14px);
animation: typewriter-text-in var(--dur) var(--ease) forwards;
animation-delay: calc(var(--i, 0) * var(--stagger));
}
/* empty with a visible caret -> revealing characters at roughly 28ms each, then holding the caret */
@keyframes typewriter-text-in {
to {
opacity: 1;
transform: none;
filter: none;
}
}
@media (prefers-reduced-motion: reduce) {
.typewriter-text > * {
opacity: 1;
transform: none;
animation: none;
}
}import { useEffect, useRef } from "react";
/** Typewriter Text — trigger: the line enters view */
export function TypewriterText({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
el.dataset["state"] = "in";
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
el.dataset["state"] = "in";
io.unobserve(el);
}
},
{ threshold: 0.2 },
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div ref={ref} className="typewriter-text" data-state="out">
{children}
</div>
);
}Copy resolves from a soft blur into perfect focus as it enters view.
Each line slides up from behind an invisible mask, one after the other.
Words fade up in sequence so the sentence assembles as you read it.
Sentences in a block arrive one at a time with a calm rhythm.