Files
Jakub Antalik e131cbe0ce feat(seo): crawlable page per transition, plus a hub and full sitemap
The library lived behind detail.html?t=slug — one indexable URL for 43
transitions, all rendered client side, so none of the long-tail queries
("css toast slide in", "accordion expand animation") could ever reach
it. /detail.html pulled a fifth of last quarter's traffic through that
single URL.

Each transition now has a static page at /transitions/<slug>/ with its
own title, description, canonical, OG tags and JSON-LD, the real CSS and
React snippets as indexable text, and links to the live demo and to six
neighbours. A hub at /transitions/ links all 43, and sitemap.xml goes
from 1 URL to 49.

Pro transitions are paywalled, so their pages carry the demo link and
the Pro pitch but no source — the scrape's stale snippets are stripped
in the generator's input.

Pages are generated by scripts/build-transition-pages.py from data
scraped out of the running detail view, so the snippets match what the
app itself serves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 14:34:12 +02:00

345 lines
17 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Like button — CSS transition | Transitions.dev</title>
<meta name="description" content="Like button: Heart fills and bursts particles. Copy-paste CSS and React, with reduced-motion support built in." />
<link rel="canonical" href="https://transitions.dev/transitions/like-button/" />
<meta name="robots" content="index, follow" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Transitions.dev" />
<meta property="og:title" content="Like button — Transitions.dev" />
<meta property="og:description" content="Like button: Heart fills and bursts particles. Copy-paste CSS and React, with reduced-motion support built in." />
<meta property="og:url" content="https://transitions.dev/transitions/like-button/" />
<meta property="og:image" content="https://transitions.dev/assets/og-main.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Like button — Transitions.dev" />
<meta name="twitter:description" content="Like button: Heart fills and bursts particles. Copy-paste CSS and React, with reduced-motion support built in." />
<meta name="twitter:image" content="https://transitions.dev/assets/og-main.jpg" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "TechArticle", "headline": "Like button \u2014 CSS transition", "description": "Like button: Heart fills and bursts particles. Copy-paste CSS and React, with reduced-motion support built in.", "url": "https://transitions.dev/transitions/like-button/", "isPartOf": {"@type": "WebSite", "name": "Transitions.dev", "url": "https://transitions.dev/"}, "author": {"@type": "Person", "name": "Jakub Antalik"}, "hasPart": {"@type": "SoftwareSourceCode", "name": "Like button CSS", "programmingLanguage": "CSS", "codeSampleType": "full solution"}}</script>
<!-- Theme boot: same key the rest of the site uses, applied before
paint so the page never flashes the wrong theme. -->
<script>
(function () {
try {
var stored = localStorage.getItem("tdev:theme");
var dark = stored
? stored === "dark"
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.setAttribute("data-theme", "dark");
} catch (e) {}
})();
</script>
<style>
:root {
color-scheme: light;
--bg: #fdfdfd;
--text: #0d0d0d;
--text-muted: #6c6c6c;
--card-bg: #ffffff;
--card-border: rgba(0, 0, 0, 0.06);
--chip-bg: #f4f4f4;
--code-bg: #f7f7f7;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: #121212;
--text: #ffffff;
--text-muted: rgba(202, 202, 202, 0.7);
--card-bg: #181818;
--card-border: rgba(255, 255, 255, 0.07);
--chip-bg: rgba(255, 255, 255, 0.06);
--code-bg: #181818;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 720px; margin: 0 auto; padding: 24px 20px 96px; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 56px; }
.brand { font-size: 15px; font-weight: 500; color: var(--text); text-decoration: none; }
.brand span { color: var(--text-muted); }
.topbar nav { display: flex; gap: 16px; }
.topbar nav a { font-size: 13px; color: var(--text-muted); text-decoration: none; }
.topbar nav a:hover { color: var(--text); }
.crumb { font-size: 13px; color: var(--text-muted); margin: 0 0 12px; }
.crumb a { color: var(--text-muted); text-decoration: none; }
.crumb a:hover { text-decoration: underline; }
h1 { font-size: 32px; line-height: 1.2; letter-spacing: -0.01em; margin: 0 0 8px; }
.lede { font-size: 16px; line-height: 24px; color: var(--text-muted); margin: 0 0 24px; }
.pro-pill {
display: inline-flex; align-items: center; height: 20px; padding: 0 8px; margin-left: 8px;
border-radius: 50px; background: rgba(0, 115, 255, 0.06); color: #0073ff;
font-size: 11px; font-weight: 500; vertical-align: middle;
}
html[data-theme="dark"] .pro-pill { background: rgba(85, 207, 255, 0.14); color: #7cd4ff; }
.cta-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 48px; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 40px; padding: 0 16px; border-radius: 26px;
font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap;
transition: background-color 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.btn--primary { background: #17181c; color: #fff; box-shadow: 0 1px 2px 0 rgba(0,0,0,0.2); }
.btn--primary:hover { background: #2a2a2a; }
html[data-theme="dark"] .btn--primary { background: #fff; color: #0d0d0d; }
.btn--secondary { background: var(--chip-bg); color: var(--text); }
h2 { font-size: 15px; font-weight: 500; margin: 40px 0 12px; }
p { font-size: 14px; line-height: 22px; color: var(--text-muted); }
pre {
margin: 0; padding: 20px; border-radius: 16px; overflow-x: auto;
background: var(--code-bg); border: 1px solid var(--card-border);
}
code { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.7; color: var(--text); }
.related { margin-top: 56px; padding-top: 24px; border-top: 1px solid var(--card-border); }
.related ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.related a {
display: inline-flex; align-items: center; height: 32px; padding: 0 12px;
border-radius: 48px; background: var(--chip-bg); color: var(--text);
font-size: 13px; text-decoration: none;
}
footer { margin-top: 56px; font-size: 13px; color: var(--text-muted); }
footer a { color: var(--text); text-decoration: none; }
</style>
</head>
<body>
<div class="wrap">
<header class="topbar">
<a class="brand" href="/">Transitions<span>.dev</span></a>
<nav>
<a href="/transitions/">All transitions</a>
<a href="/skill.html">Skill</a>
<a href="/pro.html">Pro</a>
</nav>
</header>
<p class="crumb"><a href="/">Home</a> / <a href="/transitions/">Transitions</a></p>
<h1>Like button</h1>
<p class="lede">Heart fills and bursts particles</p>
<div class="cta-row">
<a class="btn btn--primary" href="/detail.html?t=like-button">Open the live demo</a>
<a class="btn btn--secondary" href="/skill.html">Install with the Skill</a>
</div>
<h2>CSS</h2>
<p>Namespaced classes and motion tokens you can retune. The reduced-motion guard ships with the snippet.</p>
<pre><code>/* Transitions.dev — Like button */
/*
Usage:
&lt;button class=&quot;t-like&quot; data-liked=&quot;false&quot;&gt;
&lt;span class=&quot;t-like-icon&quot;&gt;&lt;svg class=&quot;t-like-heart&quot;&gt;…&lt;/svg&gt;&lt;/span&gt;
&lt;span class=&quot;t-like-particles&quot;&gt;&lt;i&gt;&lt;/i&gt;…8 total…&lt;i&gt;&lt;/i&gt;&lt;/span&gt;
Like
&lt;/button&gt;
Toggle `data-liked` to fill the heart + spring-pop it. Add
`.is-bursting` (then remove it after the particle duration)
to fire the burst; set each dot&#x27;s --px/--py/--pdur/--pdelay/
--p-end-scale/--psize in JS per like for an organic spray.
*/
:root {
--like-color: #f40051;
--like-fill: 150ms;
--like-pop: 350ms;
--like-pop-ease: cubic-bezier(0.34, 1.96, 0.64, 1);
--like-particle-dur: 600ms;
--like-particle-dist: 20px;
--like-particle-size: 2.5px;
--like-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
.t-like-heart { color: currentColor; transition: color var(--like-fill) var(--like-ease); }
.t-like-heart path {
fill: transparent; stroke: currentColor;
transition: fill var(--like-fill) var(--like-ease), stroke var(--like-fill) var(--like-ease);
}
.t-like[data-liked=&quot;true&quot;] .t-like-heart { color: var(--like-color); }
.t-like[data-liked=&quot;true&quot;] .t-like-heart path { fill: currentColor; }
/* Pop scale lives on an HTML wrapper, never the &lt;svg&gt; itself:
transforming an inline SVG makes Chromium rasterise it at 1×
(pixelated on hi-DPI). Wrapping keeps the vector crisp. */
.t-like[data-liked=&quot;true&quot;] .t-like-icon { animation: t-like-pop var(--like-pop) var(--like-pop-ease); }
@keyframes t-like-pop { 0% { transform: scale(1); } 30% { transform: scale(0.82); } 100% { transform: scale(1); } }
/* Particle burst — 8 dots flung along per-particle vectors. */
.t-like-particles { position: absolute; left: 50%; top: 50%; width: 0; height: 0; pointer-events: none; color: var(--like-color); }
.t-like-particles i {
position: absolute;
left: calc(var(--like-particle-size) * var(--psize, 1) / -2);
top: calc(var(--like-particle-size) * var(--psize, 1) / -2);
width: calc(var(--like-particle-size) * var(--psize, 1));
height: calc(var(--like-particle-size) * var(--psize, 1));
border-radius: 50%; background: currentColor; opacity: 0;
}
@keyframes t-like-burst {
0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
20% { opacity: 1; transform: translate(calc(var(--px) * 0.25), calc(var(--py) * 0.25)) scale(1); }
100% { opacity: 0; transform: translate(var(--px), var(--py)) scale(var(--p-end-scale, 0.6)); }
}
.t-like.is-bursting .t-like-particles i {
animation: t-like-burst var(--pdur, var(--like-particle-dur)) ease-out var(--pdelay, 0ms) forwards;
}
@media (prefers-reduced-motion: reduce) {
.t-like-icon, .t-like-particles i { animation: none !important; }
}</code></pre>
<h2>React</h2>
<p>Self-contained component; the styles inject themselves on first import.</p>
<pre><code>// Transitions.dev — Like button (React, self-contained)
// Drop into any React project — no extra CSS file needed.
import { useRef, useState } from &quot;react&quot;;
// ── Styles ──────────────────────────────────────────────
// Auto-injected on first import. Idempotent (guarded by
// the element id) and SSR-safe (no-ops without document).
const __TRANSITION_STYLES = `
:root {
--like-color: #f40051;
--like-fill: 150ms;
--like-pop: 350ms;
--like-pop-ease: cubic-bezier(0.34, 1.96, 0.64, 1);
--like-particle-dur: 600ms;
--like-particle-dist: 20px;
--like-particle-size: 2.5px;
--like-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
.t-like-heart { color: currentColor; transition: color var(--like-fill) var(--like-ease); }
.t-like-heart path {
fill: transparent; stroke: currentColor;
transition: fill var(--like-fill) var(--like-ease), stroke var(--like-fill) var(--like-ease);
}
.t-like[data-liked=&quot;true&quot;] .t-like-heart { color: var(--like-color); }
.t-like[data-liked=&quot;true&quot;] .t-like-heart path { fill: currentColor; }
/* Pop scale lives on an HTML wrapper, never the &lt;svg&gt; itself:
transforming an inline SVG makes Chromium rasterise it at 1×
(pixelated on hi-DPI). Wrapping keeps the vector crisp. */
.t-like[data-liked=&quot;true&quot;] .t-like-icon { animation: t-like-pop var(--like-pop) var(--like-pop-ease); }
@keyframes t-like-pop { 0% { transform: scale(1); } 30% { transform: scale(0.82); } 100% { transform: scale(1); } }
/* Particle burst — 8 dots flung along per-particle vectors. */
.t-like-particles { position: absolute; left: 50%; top: 50%; width: 0; height: 0; pointer-events: none; color: var(--like-color); }
.t-like-particles i {
position: absolute;
left: calc(var(--like-particle-size) * var(--psize, 1) / -2);
top: calc(var(--like-particle-size) * var(--psize, 1) / -2);
width: calc(var(--like-particle-size) * var(--psize, 1));
height: calc(var(--like-particle-size) * var(--psize, 1));
border-radius: 50%; background: currentColor; opacity: 0;
}
@keyframes t-like-burst {
0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
20% { opacity: 1; transform: translate(calc(var(--px) * 0.25), calc(var(--py) * 0.25)) scale(1); }
100% { opacity: 0; transform: translate(var(--px), var(--py)) scale(var(--p-end-scale, 0.6)); }
}
.t-like.is-bursting .t-like-particles i {
animation: t-like-burst var(--pdur, var(--like-particle-dur)) ease-out var(--pdelay, 0ms) forwards;
}
@media (prefers-reduced-motion: reduce) {
.t-like-icon, .t-like-particles i { animation: none !important; }
}
`;
if (typeof document !== &quot;undefined&quot; &amp;&amp; !document.getElementById(&quot;transitions-p23&quot;)) {
const __style = document.createElement(&quot;style&quot;);
__style.id = &quot;transitions-p23&quot;;
__style.textContent = __TRANSITION_STYLES;
document.head.appendChild(__style);
}
// Pair with the CSS from the CSS tab. Liking fills the heart, springs a
// pop, and fires an 8-dot burst whose per-particle vectors / velocity /
// delay / size are re-randomised each time so the spray never repeats.
const DIST = 20; // px — matches --like-particle-dist
export function LikeButton() {
const [liked, setLiked] = useState(false);
const ref = useRef(null);
function seedParticles() {
const dots = ref.current.querySelectorAll(&quot;.t-like-particles i&quot;);
dots.forEach((dot, i) =&gt; {
const angle = (360 / dots.length) * i + (Math.random() * 2 - 1) * 16;
const mag = DIST * (0.68 + Math.random() * 0.5);
const rad = (angle * Math.PI) / 180;
const s = dot.style;
s.setProperty(&quot;--px&quot;, `${(Math.cos(rad) * mag).toFixed(2)}px`);
s.setProperty(&quot;--py&quot;, `${(Math.sin(rad) * mag).toFixed(2)}px`);
s.setProperty(&quot;--pdur&quot;, `calc(var(--like-particle-dur) * ${(0.78 + Math.random() * 0.44).toFixed(3)})`);
s.setProperty(&quot;--pdelay&quot;, `${Math.round(Math.random() * 70)}ms`);
s.setProperty(&quot;--p-end-scale&quot;, (0.35 + Math.random() * 0.4).toFixed(2));
s.setProperty(&quot;--psize&quot;, (0.6 + Math.random() * 0.8).toFixed(2));
});
}
function toggle() {
const el = ref.current;
if (liked) {
setLiked(false);
el.classList.remove(&quot;is-bursting&quot;);
return;
}
setLiked(true);
el.classList.remove(&quot;is-bursting&quot;);
seedParticles();
void el.offsetWidth; // reflow so the burst replays
el.classList.add(&quot;is-bursting&quot;);
}
return (
&lt;button
ref={ref}
type=&quot;button&quot;
className=&quot;t-like&quot;
data-liked={liked ? &quot;true&quot; : &quot;false&quot;}
aria-pressed={liked}
onClick={toggle}
&gt;
&lt;span className=&quot;t-like-icon&quot;&gt;
&lt;svg width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 16 16&quot; fill=&quot;none&quot;&gt;
&lt;path
d=&quot;M7.99511 3.42388C6.66221 1.8656 4.4395 1.44643 2.76947 2.87334C1.09944 4.30026 0.86432 6.68598 2.17581 8.3736C3.26622 9.77674 6.56619 12.7361 7.64774 13.6939C7.76874 13.801 7.82925 13.8546 7.89982 13.8757C7.96141 13.8941 8.02881 13.8941 8.0904 13.8757C8.16097 13.8546 8.22147 13.801 8.34248 13.6939C9.42403 12.7361 12.724 9.77674 13.8144 8.3736C15.1259 6.68598 14.9195 4.28525 13.2207 2.87334C11.522 1.46144 9.32801 1.8656 7.99511 3.42388Z&quot;
className=&quot;t-like-heart-path&quot;
stroke=&quot;currentColor&quot;
strokeWidth=&quot;1.5&quot;
strokeLinecap=&quot;round&quot;
strokeLinejoin=&quot;round&quot;
/&gt;
&lt;/svg&gt;
&lt;/span&gt;
&lt;span className=&quot;t-like-particles&quot; aria-hidden=&quot;true&quot;&gt;
{Array.from({ length: 8 }, (_, i) =&gt; &lt;i key={i} /&gt;)}
&lt;/span&gt;
Like
&lt;/button&gt;
);
}
</code></pre>
<div class="related">
<h2>More transitions</h2>
<ul>
<li><a href="/transitions/learn-more-hover/">Learn more hover</a></li>
<li><a href="/transitions/checkbox-check/">Checkbox check</a></li>
<li><a href="/transitions/spinner-to-check-morph/">Spinner to check morph</a></li>
<li><a href="/transitions/spinning-counter/">Spinning counter</a></li>
<li><a href="/transitions/toggle/">Toggle</a></li>
<li><a href="/transitions/pro-gradient-text/">Pro gradient text</a></li>
</ul>
</div>
<footer><a href="/transitions/toast-open-close/">&larr; Toast open/close</a> &middot; <a href="/transitions/learn-more-hover/">Learn more hover &rarr;</a></footer>
</div>
</body>
</html>