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

264 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Toggle — CSS transition | Transitions.dev</title>
<meta name="description" content="Toggle: Thumb slides with a double bounce. Copy-paste CSS and React, with reduced-motion support built in." />
<link rel="canonical" href="https://transitions.dev/transitions/toggle/" />
<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="Toggle — Transitions.dev" />
<meta property="og:description" content="Toggle: Thumb slides with a double bounce. Copy-paste CSS and React, with reduced-motion support built in." />
<meta property="og:url" content="https://transitions.dev/transitions/toggle/" />
<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="Toggle — Transitions.dev" />
<meta name="twitter:description" content="Toggle: Thumb slides with a double bounce. 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": "Toggle \u2014 CSS transition", "description": "Toggle: Thumb slides with a double bounce. Copy-paste CSS and React, with reduced-motion support built in.", "url": "https://transitions.dev/transitions/toggle/", "isPartOf": {"@type": "WebSite", "name": "Transitions.dev", "url": "https://transitions.dev/"}, "author": {"@type": "Person", "name": "Jakub Antalik"}, "hasPart": {"@type": "SoftwareSourceCode", "name": "Toggle 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>Toggle</h1>
<p class="lede">Thumb slides with a double bounce</p>
<div class="cta-row">
<a class="btn btn--primary" href="/detail.html?t=toggle">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 — Toggle */
/*
Usage:
&lt;button class=&quot;t-toggle&quot; role=&quot;switch&quot; data-on=&quot;false&quot;&gt;
&lt;span class=&quot;t-toggle-thumb&quot;&gt;&lt;/span&gt;
&lt;/button&gt;
Toggle `data-on`. Add `.is-init` on first interaction so the
&quot;off&quot; keyframes don&#x27;t play on load. The thumb travels with a
double bounce (overshoot past the end, swing back, settle);
the track colour cross-fades on its own clock.
*/
:root {
--toggle-dur: 350ms;
--toggle-travel: 14.66px;
--toggle-ov1: 1px;
--toggle-ov2: 0px;
--toggle-track: 0ms;
--toggle-ease: cubic-bezier(0.34, 1.35, 0.64, 1);
}
.t-toggle { transition: background var(--toggle-track) var(--toggle-ease); }
.t-toggle-thumb { translate: 0 0; will-change: translate; }
.t-toggle[data-on=&quot;true&quot;] .t-toggle-thumb { translate: var(--toggle-travel) 0; }
.t-toggle.is-init[data-on=&quot;true&quot;] .t-toggle-thumb { animation: t-toggle-on var(--toggle-dur) var(--toggle-ease) both; }
.t-toggle.is-init[data-on=&quot;false&quot;] .t-toggle-thumb { animation: t-toggle-off var(--toggle-dur) var(--toggle-ease) both; }
@keyframes t-toggle-on {
0% { translate: 0 0; }
55% { translate: calc(var(--toggle-travel) + var(--toggle-ov1)) 0; }
80% { translate: calc(var(--toggle-travel) - var(--toggle-ov2)) 0; }
100% { translate: var(--toggle-travel) 0; }
}
@keyframes t-toggle-off {
0% { translate: var(--toggle-travel) 0; }
55% { translate: calc(0px - var(--toggle-ov1)) 0; }
80% { translate: var(--toggle-ov2) 0; }
100% { translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.t-toggle-thumb { animation: none !important; }
}</code></pre>
<h2>React</h2>
<p>Self-contained component; the styles inject themselves on first import.</p>
<pre><code>// Transitions.dev — Toggle (React, self-contained)
// Drop into any React project — no extra CSS file needed.
import { 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 {
--toggle-dur: 350ms;
--toggle-travel: 14.66px;
--toggle-ov1: 1px;
--toggle-ov2: 0px;
--toggle-track: 0ms;
--toggle-ease: cubic-bezier(0.34, 1.35, 0.64, 1);
}
.t-toggle { transition: background var(--toggle-track) var(--toggle-ease); }
.t-toggle-thumb { translate: 0 0; will-change: translate; }
.t-toggle[data-on=&quot;true&quot;] .t-toggle-thumb { translate: var(--toggle-travel) 0; }
.t-toggle.is-init[data-on=&quot;true&quot;] .t-toggle-thumb { animation: t-toggle-on var(--toggle-dur) var(--toggle-ease) both; }
.t-toggle.is-init[data-on=&quot;false&quot;] .t-toggle-thumb { animation: t-toggle-off var(--toggle-dur) var(--toggle-ease) both; }
@keyframes t-toggle-on {
0% { translate: 0 0; }
55% { translate: calc(var(--toggle-travel) + var(--toggle-ov1)) 0; }
80% { translate: calc(var(--toggle-travel) - var(--toggle-ov2)) 0; }
100% { translate: var(--toggle-travel) 0; }
}
@keyframes t-toggle-off {
0% { translate: var(--toggle-travel) 0; }
55% { translate: calc(0px - var(--toggle-ov1)) 0; }
80% { translate: var(--toggle-ov2) 0; }
100% { translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.t-toggle-thumb { animation: none !important; }
}
`;
if (typeof document !== &quot;undefined&quot; &amp;&amp; !document.getElementById(&quot;transitions-p27&quot;)) {
const __style = document.createElement(&quot;style&quot;);
__style.id = &quot;transitions-p27&quot;;
__style.textContent = __TRANSITION_STYLES;
document.head.appendChild(__style);
}
// Pair with the CSS from the CSS tab. `.is-init` gates the keyframes so the
// &quot;off&quot; animation doesn&#x27;t play on mount; flipping `data-on` swaps the
// animation, which restarts the double-bounce thumb travel.
export function Toggle() {
const [on, setOn] = useState(false);
const [init, setInit] = useState(false);
return (
&lt;button
type=&quot;button&quot;
className={&quot;t-toggle&quot; + (init ? &quot; is-init&quot; : &quot;&quot;)}
role=&quot;switch&quot;
aria-checked={on}
data-on={on ? &quot;true&quot; : &quot;false&quot;}
onClick={() =&gt; { setInit(true); setOn((v) =&gt; !v); }}
&gt;
&lt;span className=&quot;t-toggle-thumb&quot; aria-hidden=&quot;true&quot; /&gt;
&lt;/button&gt;
);
}
</code></pre>
<div class="related">
<h2>More transitions</h2>
<ul>
<li><a href="/transitions/pro-gradient-text/">Pro gradient text</a></li>
<li><a href="/transitions/delete-with-smoky-dissolve/">Delete with smoky dissolve</a></li>
<li><a href="/transitions/thinking-states/">Thinking states</a></li>
<li><a href="/transitions/reasoning-stream/">Reasoning stream</a></li>
<li><a href="/transitions/streaming-text/">Streaming text</a></li>
<li><a href="/transitions/matrix-dot-loader/">Matrix dot loader</a></li>
</ul>
</div>
<footer><a href="/transitions/spinning-counter/">&larr; Spinning counter</a> &middot; <a href="/transitions/pro-gradient-text/">Pro gradient text &rarr;</a></footer>
</div>
</body>
</html>