A headline that breathes. Every letter animates its font weight from light to black and back again, and each one starts slightly after its neighbour, so thick and thin roll across the words like a slow swell. It is pure CSS: one keyframe rule and a delay per letter. The demo is a calm landing page for a breathing app, with soft rings that expand and contract behind the type, a paragraph, a button and two session cards. On phones the headline scales with the screen width and the cards stack.
// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/font-weight-text.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import type { CSSProperties } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { Moon, Play, Wind } from "lucide-react";
const sessions = [
{ icon: Wind, name: "Box breathing", length: "4 min", note: "Four counts in, hold, out, hold" },
{ icon: Moon, name: "Wind down", length: "9 min", note: "Longer exhales before sleep" },
];
/**
* Text whose letters swell from thin to heavy and back, each a beat behind
* its neighbour, so a wave of weight rolls through the word.
* Needs a variable font: the system font on current Apple, Windows and Android devices is one.
*/
function WeightWave({ text, min = 200, max = 900, seconds = 2.4, stagger = 0.16 }: { text: string; min?: number; max?: number; seconds?: number; stagger?: number }) {
const mid = text.length / 2;
return (
<span aria-label={text} role="text" className="inline-block whitespace-nowrap" style={{ "--wave-min": min, "--wave-max": max } as CSSProperties}>
{text.split("").map((char, i) => (
<span
key={i}
aria-hidden="true"
className="breathing-font-weight-wave-headline-letter"
style={{ animationDuration: `${seconds}s`, animationDelay: `${((i - mid) * stagger - seconds * 2).toFixed(2)}s` }}
>
{char === " " ? " " : char}
</span>
))}
</span>
);
}
export default function BreathingFontWeightWaveHeadline() {
const reduce = useReducedMotion();
const rise = (delay: number) => ({
initial: reduce ? false : { opacity: 0, y: 16 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] as const },
});
return (
<section className="relative overflow-hidden bg-[#eaf3f6] px-4 py-20 text-slate-900 sm:px-6 sm:py-28">
<style>{`
@keyframes breathing-font-weight-wave-headline-breath {
from { font-weight: var(--wave-min); font-variation-settings: "wght" var(--wave-min); }
to { font-weight: var(--wave-max); font-variation-settings: "wght" var(--wave-max); }
}
.breathing-font-weight-wave-headline-letter {
display: inline-block;
font-weight: var(--wave-min);
animation-name: breathing-font-weight-wave-headline-breath;
animation-timing-function: cubic-bezier(0.37, 0, 0.63, 1);
animation-direction: alternate;
animation-iteration-count: infinite;
animation-fill-mode: both;
}
@keyframes breathing-font-weight-wave-headline-ring {
from { transform: scale(0.82); opacity: 0.55; }
to { transform: scale(1.12); opacity: 0.15; }
}
@media (prefers-reduced-motion: reduce) {
.breathing-font-weight-wave-headline-letter { animation: none; font-weight: 600; font-variation-settings: "wght" 600; }
.breathing-font-weight-wave-headline-ring { animation: none !important; }
}
`}</style>
<div className="pointer-events-none absolute left-1/2 top-24 -translate-x-1/2" aria-hidden="true">
{[520, 760, 1000].map((size, i) => (
<span
key={size}
className="breathing-font-weight-wave-headline-ring absolute left-1/2 top-1/2 -ml-[var(--r)] -mt-[var(--r)] rounded-full border border-teal-700/20 bg-white/30"
style={{ "--r": `${size / 2}px`, width: size, height: size, animation: `breathing-font-weight-wave-headline-ring 4.8s cubic-bezier(0.37,0,0.63,1) ${-i * 0.5}s infinite alternate` } as CSSProperties}
/>
))}
</div>
<div className="relative mx-auto flex max-w-4xl flex-col items-center text-center">
<motion.p {...rise(0)} className="rounded-full bg-white/70 px-4 py-1.5 text-sm font-medium text-teal-900">
Stillwater, a breathing coach for busy heads
</motion.p>
<h1 className="mt-10 text-[13.5vw] leading-[1.02] tracking-tight sm:text-8xl lg:text-9xl">
<WeightWave text="Breathe in." />
<br />
<span className="text-teal-700">
<WeightWave text="Breathe out." stagger={-0.16} />
</span>
</h1>
<motion.p {...rise(0.15)} className="mt-9 max-w-lg text-lg leading-relaxed text-slate-700">
Short guided sessions that follow a slow, steady rhythm. Pick one, put the phone face up on the desk and follow the type.
</motion.p>
<motion.div {...rise(0.25)} className="mt-9">
<a href="#" className="inline-flex h-12 items-center gap-2.5 rounded-full bg-slate-900 px-7 text-sm font-semibold text-white transition-colors hover:bg-teal-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-700 focus-visible:ring-offset-2 focus-visible:ring-offset-[#eaf3f6]">
<Play className="h-4 w-4 fill-current" aria-hidden="true" /> Start a two minute session
</a>
</motion.div>
<motion.ul {...rise(0.35)} className="mt-14 grid w-full max-w-2xl gap-3 text-left sm:grid-cols-2">
{sessions.map((s) => (
<li key={s.name} className="flex items-center gap-4 rounded-2xl bg-white/80 p-4 ring-1 ring-slate-900/5">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-teal-700 text-white">
<s.icon className="h-5 w-5" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-3">
<span className="font-semibold">{s.name}</span>
<span className="text-sm tabular-nums text-slate-600">{s.length}</span>
</span>
<span className="mt-0.5 block text-sm text-slate-600">{s.note}</span>
</span>
</li>
))}
</motion.ul>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/breathing-font-weight-wave-headline.json
It needs a variable font with a weight axis to move smoothly. The default system font on current Apple, Windows 11 and Android devices is one, which is what the demo uses. With a static font the letters step between the weights that are installed instead of gliding.
Heavier letters are wider, so the line gets slightly longer as the wave passes. That movement is part of the look. If you need fixed positions, use a variable font with a width-compensated or monospaced design.
Every letter runs the same alternating keyframe animation. Each gets a different negative `animation-delay`, calculated from its position, so they are all at a different point in the cycle from the first frame.
It triggers text layout for the animated letters on each frame, which is fine for a headline of a few dozen characters. Do not apply it to paragraphs or long lists.
Yes. Add a class that sets `animation-play-state: paused` on the letters and toggle it from an IntersectionObserver or framer-motion's `useInView`.
Adapted from Font Weight Text in Eldora UI, used under its open-source licence. Licences and credits
Related: Headline Letters That React to Cursor Proximity, Morphing Text Headline with Liquid Blur, Aurora Gradient Headline Text.
More text effects or all free UI components. Start for free to have a full site built around it.
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.