mirror of
https://github.com/Jakubantalik/transitions.dev.git
synced 2026-09-14 20:26:33 +08:00
e131cbe0ce
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>
264 lines
12 KiB
HTML
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:
|
|
<button class="t-toggle" role="switch" data-on="false">
|
|
<span class="t-toggle-thumb"></span>
|
|
</button>
|
|
|
|
Toggle `data-on`. Add `.is-init` on first interaction so the
|
|
"off" keyframes don'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="true"] .t-toggle-thumb { translate: var(--toggle-travel) 0; }
|
|
.t-toggle.is-init[data-on="true"] .t-toggle-thumb { animation: t-toggle-on var(--toggle-dur) var(--toggle-ease) both; }
|
|
.t-toggle.is-init[data-on="false"] .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 "react";
|
|
|
|
// ── 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="true"] .t-toggle-thumb { translate: var(--toggle-travel) 0; }
|
|
.t-toggle.is-init[data-on="true"] .t-toggle-thumb { animation: t-toggle-on var(--toggle-dur) var(--toggle-ease) both; }
|
|
.t-toggle.is-init[data-on="false"] .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 !== "undefined" && !document.getElementById("transitions-p27")) {
|
|
const __style = document.createElement("style");
|
|
__style.id = "transitions-p27";
|
|
__style.textContent = __TRANSITION_STYLES;
|
|
document.head.appendChild(__style);
|
|
}
|
|
|
|
// Pair with the CSS from the CSS tab. `.is-init` gates the keyframes so the
|
|
// "off" animation doesn'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 (
|
|
<button
|
|
type="button"
|
|
className={"t-toggle" + (init ? " is-init" : "")}
|
|
role="switch"
|
|
aria-checked={on}
|
|
data-on={on ? "true" : "false"}
|
|
onClick={() => { setInit(true); setOn((v) => !v); }}
|
|
>
|
|
<span className="t-toggle-thumb" aria-hidden="true" />
|
|
</button>
|
|
);
|
|
}
|
|
</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/">← Spinning counter</a> · <a href="/transitions/pro-gradient-text/">Pro gradient text →</a></footer>
|
|
</div>
|
|
</body>
|
|
</html>
|