22 CSS Transitions04 / 22

Pure CSSMIT licensed

Custom Easing with the linear Timing Function

cubic-bezier() can only describe a single smooth curve, so a bounce that settles twice is impossible to express. The linear() timing function takes a list of stops instead and approximates springs, bounces and stepped eases directly in a transition. Three markers run the same distance under three linear() strings, each printed beside its track, behind an @supports gate with a bezier fallback declared first.

Published Updated

Live Demo
Try it

The code

<section class="trn-04" aria-labelledby="trn-04-heading">
  <div class="trn-04__stage">
    <div class="trn-04__theme">
      <input class="trn-04__themecb" type="checkbox" id="trn-04-theme">
      <label class="trn-04__themebtn" for="trn-04-theme">
        <span class="trn-04__themeico" aria-hidden="true">
          <svg class="trn-04__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
          <svg class="trn-04__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
        </span>
        <span>Theme</span>
      </label>
    </div>
    <header class="trn-04__head">
      <p class="trn-04__kicker">timing-function: linear()</p>
      <h2 class="trn-04__heading" id="trn-04-heading">Easing that changes direction</h2>
      <p class="trn-04__sub">One state change, three easing polylines. Every value string is printed as it ships.</p>
    </header>

    <div class="trn-04__lab">
      <input class="trn-04__switch" type="checkbox" id="trn-04-run">
      <label class="trn-04__run" for="trn-04-run"><span class="trn-04__runin">Send</span><span class="trn-04__runout">Return</span></label>

      <ul class="trn-04__list">
        <li class="trn-04__row">
          <div class="trn-04__meta"><span class="trn-04__name">bounce</span><span class="trn-04__tag">settles twice</span></div>
          <div class="trn-04__track"><span class="trn-04__runner trn-04__runner--bounce"><span class="trn-04__chip">Deploy</span></span></div>
          <code class="trn-04__value">linear(0, 1.06 38%, 0.94 55%, 1.02 72%, 0.99 86%, 1)</code>
        </li>
        <li class="trn-04__row">
          <div class="trn-04__meta"><span class="trn-04__name">spring</span><span class="trn-04__tag">one long overshoot</span></div>
          <div class="trn-04__track"><span class="trn-04__runner trn-04__runner--spring"><span class="trn-04__chip">Sync</span></span></div>
          <code class="trn-04__value">linear(0, 0.38 18%, 0.86 38%, 1.12 58%, 1.02 80%, 1)</code>
        </li>
        <li class="trn-04__row">
          <div class="trn-04__meta"><span class="trn-04__name">stepped</span><span class="trn-04__tag">holds mid-flight</span></div>
          <div class="trn-04__track"><span class="trn-04__runner trn-04__runner--step"><span class="trn-04__chip">Invite</span></span></div>
          <code class="trn-04__value">linear(0, 0.3 20%, 0.3 45%, 0.72 62%, 0.72 78%, 1)</code>
        </li>
      </ul>

      <p class="trn-04__gate">Base rule ships <code>cubic-bezier(.34, 1.16, .64, 1)</code>. The polylines are applied inside <code>@supports (transition-timing-function: linear(0, 1))</code>, so an engine without <code>linear()</code> still moves — it just cannot change direction twice.</p>
    </div>
  </div>
</section>
.trn-04 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --page: #000000;
  --card: #08090b;
  --ink: #f2fff5;
  --ink: oklch(0.98 0.02 155);
  --muted: #7c8a80;
  --muted: oklch(0.63 0.02 155);
  --rule: #1a1d1b;
  --accent: #4dff8f;
  --accent: oklch(0.88 0.21 148);
  --dur: 620ms;
  --font-display: ui-monospace, "JetBrains Mono", Menlo, monospace;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

.trn-04 *,
.trn-04 *::before,
.trn-04 *::after {
  box-sizing: border-box;
}

.trn-04__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  padding: clamp(28px, 6vw, 68px) 16px;
}

.trn-04__head {
  inline-size: 100%;
  max-inline-size: 56rem;
  min-inline-size: 0;
  margin: 0 0 32px;
}

.trn-04__kicker {
  margin: 0 0 14px;
  font: 400 11.5px/1 var(--font-display);
  letter-spacing: 0.1em;
  color: var(--accent);
}

.trn-04__heading {
  margin: 0 0 12px;
  font: 400 clamp(26px, 5vw, 40px)/1.08 var(--font-display);
  letter-spacing: -0.03em;
  text-wrap: balance;
}

.trn-04__sub {
  margin: 0;
  max-inline-size: 50ch;
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.trn-04__lab {
  inline-size: 100%;
  max-inline-size: 56rem;
  min-inline-size: 0;
}

.trn-04__switch {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  clip-path: inset(50%);
  overflow: hidden;
}

.trn-04__run {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  min-inline-size: 118px;
  padding: 0 20px;
  border: 1px solid var(--accent);
  border-radius: 999px;
  cursor: pointer;
  font: 500 13px/1 var(--font-display);
  letter-spacing: 0.06em;
  color: var(--accent);
  text-transform: uppercase;
  transition: background-color 200ms ease, color 200ms ease;
}

.trn-04__run:hover {
  background: rgba(77, 255, 143, 0.12);
}

.trn-04__switch:focus-visible + .trn-04__run {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

.trn-04__switch:checked + .trn-04__run {
  background: var(--accent);
  color: #000;
}

.trn-04__runout {
  display: none;
}

.trn-04__switch:checked + .trn-04__run .trn-04__runin {
  display: none;
}

.trn-04__switch:checked + .trn-04__run .trn-04__runout {
  display: inline;
}

.trn-04__list {
  list-style: none;
  margin: 34px 0 0;
  padding: 0;
  display: grid;
  gap: 30px;
}

.trn-04__row {
  min-inline-size: 0;
  display: grid;
  gap: 10px;
}

.trn-04__meta {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 12px;
  align-items: baseline;
}

.trn-04__name {
  font: 500 15px/1 var(--font-display);
  color: var(--ink);
}

.trn-04__tag {
  font-size: 12px;
  color: var(--muted);
}

.trn-04__track {
  position: relative;
  block-size: 38px;
  border-block-end: 1px solid var(--rule);
}

.trn-04__runner {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  inline-size: 100%;
  transition: transform var(--dur) cubic-bezier(.34, 1.16, .64, 1);
}

.trn-04__chip {
  display: inline-flex;
  align-items: center;
  min-height: 30px;
  padding: 0 12px;
  background: var(--card);
  border: 1px solid var(--accent);
  border-radius: 4px;
  font: 500 12px/1 var(--font-display);
  color: var(--accent);
  box-shadow: 0 0 22px -6px var(--accent);
}

.trn-04:has(#trn-04-run:checked) .trn-04__runner {
  transform: translateX(calc(100% - 78px));
}

@supports (transition-timing-function: linear(0, 1)) {
  .trn-04__runner--bounce {
    transition-timing-function: linear(0, 1.06 38%, 0.94 55%, 1.02 72%, 0.99 86%, 1);
  }

  .trn-04__runner--spring {
    transition-timing-function: linear(0, 0.38 18%, 0.86 38%, 1.12 58%, 1.02 80%, 1);
  }

  .trn-04__runner--step {
    transition-timing-function: linear(0, 0.3 20%, 0.3 45%, 0.72 62%, 0.72 78%, 1);
  }
}

.trn-04__value {
  display: block;
  font: 400 11.5px/1.55 var(--font-display);
  color: var(--muted);
  overflow-wrap: anywhere;
}

.trn-04__gate {
  margin: 34px 0 0;
  padding: 14px 16px;
  border: 1px solid var(--rule);
  border-radius: 6px;
  font-size: 12.5px;
  line-height: 1.65;
  color: var(--muted);
}

.trn-04__gate code {
  font: 400 11.5px/1 var(--font-display);
  color: var(--ink);
  overflow-wrap: anywhere;
}

@media (prefers-reduced-motion: reduce) {
  .trn-04__runner {
    transition: transform 160ms linear, opacity 160ms linear;
  }

  .trn-04__runner--bounce,
    .trn-04__runner--spring,
    .trn-04__runner--step {
    transition-timing-function: linear;
  }

  .trn-04:has(#trn-04-run:checked) .trn-04__runner {
    transform: translateX(calc(100% - 78px));
  }
}

@media (prefers-color-scheme: light) {
  .trn-04 {
    --page: #fbfdfb;
    --card: #ffffff;
    --ink: #0a1410;
    --muted: #5c6b62;
    --rule: #dde5e0;
    --accent: #0f7d43;
  }

  .trn-04__chip {
    box-shadow: 0 2px 10px -4px rgba(15, 125, 67, 0.5);
  }

  .trn-04__switch:checked + .trn-04__run {
    color: #fbfdfb;
  }
}

[data-theme="dark"] .trn-04 {
  --page: #000000;
  --card: #08090b;
  --ink: #f2fff5;
  --muted: #7c8a80;
  --rule: #1a1d1b;
  --accent: #4dff8f;
}

[data-theme="dark"] .trn-04__chip {
  box-shadow: 0 0 22px -6px var(--accent);
}

[data-theme="dark"] .trn-04__switch:checked + .trn-04__run {
  color: #000;
}

.trn-04__theme {
  position: relative;
  inline-size: 100%;
  min-inline-size: 0;
  display: flex;
  justify-content: flex-end;
  margin: 0 0 14px;
}

.trn-04__themecb {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  clip-path: inset(50%);
  overflow: hidden;
}

.trn-04__themebtn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid currentColor;
  border-radius: 999px;
  cursor: pointer;
  color: var(--muted);
  font: 500 11px/1 ui-monospace, Menlo, monospace;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition: color 200ms ease, opacity 200ms ease;
}

.trn-04__themebtn:hover {
  color: var(--ink);
}

.trn-04__themecb:focus-visible + .trn-04__themebtn {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.trn-04__themeico {
  display: grid;
  place-items: center;
  inline-size: 15px;
  block-size: 15px;
}

.trn-04__themeico svg {
  grid-area: 1 / 1;
  transition: opacity 200ms ease;
}

.trn-04__sun {
  opacity: 0.34;
}

.trn-04__moon {
  opacity: 1;
}

.trn-04:has(#trn-04-theme:checked) .trn-04__sun {
  opacity: 1;
}

.trn-04:has(#trn-04-theme:checked) .trn-04__moon {
  opacity: 0.34;
}

@media (prefers-color-scheme: dark) {
  .trn-04:has(#trn-04-theme:checked) {
    --page: #fbfdfb;
    --card: #ffffff;
    --ink: #0a1410;
    --muted: #5c6b62;
    --rule: #dde5e0;
    --accent: #0f7d43;
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__chip {
    box-shadow: 0 2px 10px -4px rgba(15, 125, 67, 0.5);
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__switch:checked + .trn-04__run {
    color: #fbfdfb;
  }
}

@media (prefers-color-scheme: light) {
  .trn-04:has(#trn-04-theme:checked) {
    --page: #000000;
    --card: #08090b;
    --ink: #f2fff5;
    --ink: oklch(0.98 0.02 155);
    --muted: #7c8a80;
    --muted: oklch(0.63 0.02 155);
    --rule: #1a1d1b;
    --accent: #4dff8f;
    --accent: oklch(0.88 0.21 148);
    --dur: 620ms;
    --font-display: ui-monospace, "JetBrains Mono", Menlo, monospace;
    background: var(--page);
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__chip {
    display: inline-flex;
    align-items: center;
    min-height: 30px;
    padding: 0 12px;
    background: var(--card);
    border: 1px solid var(--accent);
    border-radius: 4px;
    font: 500 12px/1 var(--font-display);
    color: var(--accent);
    box-shadow: 0 0 22px -6px var(--accent);
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__switch:checked + .trn-04__run {
    background: var(--accent);
    color: #000;
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__sun {
    opacity: 0.34;
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__moon {
    opacity: 1;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Transition from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Custom Easing with the linear Timing Function
Source: https://codefronts.com/motion/css-transition-designs/text-reveal-animation/

cubic-bezier() can only describe a single smooth curve, so a bounce that settles twice is impossible to express. The linear() timing function takes a list of stops instead and approximates springs, bounces and stepped eases directly in a transition. Three markers run the same distance under three linear() strings, each printed beside its track, behind an @supports gate with a bezier fallback declared first.
## HTML
```html
<section class="trn-04" aria-labelledby="trn-04-heading">
  <div class="trn-04__stage">
    <div class="trn-04__theme">
      <input class="trn-04__themecb" type="checkbox" id="trn-04-theme">
      <label class="trn-04__themebtn" for="trn-04-theme">
        <span class="trn-04__themeico" aria-hidden="true">
          <svg class="trn-04__sun" viewBox="0 0 20 20" width="15" height="15"><circle cx="10" cy="10" r="3.9" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 1.7v2.1M10 16.2v2.1M1.7 10h2.1M16.2 10h2.1M4.2 4.2l1.5 1.5M14.3 14.3l1.5 1.5M15.8 4.2l-1.5 1.5M5.7 14.3l-1.5 1.5" stroke="currentColor" stroke-width="1.4"/></svg>
          <svg class="trn-04__moon" viewBox="0 0 20 20" width="15" height="15"><path d="M15.4 12.7A6.7 6.7 0 0 1 7.3 4.6a6.8 6.8 0 1 0 8.1 8.1Z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
        </span>
        <span>Theme</span>
      </label>
    </div>
    <header class="trn-04__head">
      <p class="trn-04__kicker">timing-function: linear()</p>
      <h2 class="trn-04__heading" id="trn-04-heading">Easing that changes direction</h2>
      <p class="trn-04__sub">One state change, three easing polylines. Every value string is printed as it ships.</p>
    </header>

    <div class="trn-04__lab">
      <input class="trn-04__switch" type="checkbox" id="trn-04-run">
      <label class="trn-04__run" for="trn-04-run"><span class="trn-04__runin">Send</span><span class="trn-04__runout">Return</span></label>

      <ul class="trn-04__list">
        <li class="trn-04__row">
          <div class="trn-04__meta"><span class="trn-04__name">bounce</span><span class="trn-04__tag">settles twice</span></div>
          <div class="trn-04__track"><span class="trn-04__runner trn-04__runner--bounce"><span class="trn-04__chip">Deploy</span></span></div>
          <code class="trn-04__value">linear(0, 1.06 38%, 0.94 55%, 1.02 72%, 0.99 86%, 1)</code>
        </li>
        <li class="trn-04__row">
          <div class="trn-04__meta"><span class="trn-04__name">spring</span><span class="trn-04__tag">one long overshoot</span></div>
          <div class="trn-04__track"><span class="trn-04__runner trn-04__runner--spring"><span class="trn-04__chip">Sync</span></span></div>
          <code class="trn-04__value">linear(0, 0.38 18%, 0.86 38%, 1.12 58%, 1.02 80%, 1)</code>
        </li>
        <li class="trn-04__row">
          <div class="trn-04__meta"><span class="trn-04__name">stepped</span><span class="trn-04__tag">holds mid-flight</span></div>
          <div class="trn-04__track"><span class="trn-04__runner trn-04__runner--step"><span class="trn-04__chip">Invite</span></span></div>
          <code class="trn-04__value">linear(0, 0.3 20%, 0.3 45%, 0.72 62%, 0.72 78%, 1)</code>
        </li>
      </ul>

      <p class="trn-04__gate">Base rule ships <code>cubic-bezier(.34, 1.16, .64, 1)</code>. The polylines are applied inside <code>@supports (transition-timing-function: linear(0, 1))</code>, so an engine without <code>linear()</code> still moves — it just cannot change direction twice.</p>
    </div>
  </div>
</section>
```
## CSS
```css
.trn-04 {
  width: 100%;
  min-height: 100vh;
  display: block;
  box-sizing: border-box;
  --page: #000000;
  --card: #08090b;
  --ink: #f2fff5;
  --ink: oklch(0.98 0.02 155);
  --muted: #7c8a80;
  --muted: oklch(0.63 0.02 155);
  --rule: #1a1d1b;
  --accent: #4dff8f;
  --accent: oklch(0.88 0.21 148);
  --dur: 620ms;
  --font-display: ui-monospace, "JetBrains Mono", Menlo, monospace;
  background: var(--page);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}

.trn-04 *,
.trn-04 *::before,
.trn-04 *::after {
  box-sizing: border-box;
}

.trn-04__stage {
  display: grid;
  place-items: start center;
  max-inline-size: 100%;
  padding: clamp(28px, 6vw, 68px) 16px;
}

.trn-04__head {
  inline-size: 100%;
  max-inline-size: 56rem;
  min-inline-size: 0;
  margin: 0 0 32px;
}

.trn-04__kicker {
  margin: 0 0 14px;
  font: 400 11.5px/1 var(--font-display);
  letter-spacing: 0.1em;
  color: var(--accent);
}

.trn-04__heading {
  margin: 0 0 12px;
  font: 400 clamp(26px, 5vw, 40px)/1.08 var(--font-display);
  letter-spacing: -0.03em;
  text-wrap: balance;
}

.trn-04__sub {
  margin: 0;
  max-inline-size: 50ch;
  font-size: 14.5px;
  line-height: 1.6;
  color: var(--muted);
  text-wrap: pretty;
}

.trn-04__lab {
  inline-size: 100%;
  max-inline-size: 56rem;
  min-inline-size: 0;
}

.trn-04__switch {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  clip-path: inset(50%);
  overflow: hidden;
}

.trn-04__run {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  min-inline-size: 118px;
  padding: 0 20px;
  border: 1px solid var(--accent);
  border-radius: 999px;
  cursor: pointer;
  font: 500 13px/1 var(--font-display);
  letter-spacing: 0.06em;
  color: var(--accent);
  text-transform: uppercase;
  transition: background-color 200ms ease, color 200ms ease;
}

.trn-04__run:hover {
  background: rgba(77, 255, 143, 0.12);
}

.trn-04__switch:focus-visible + .trn-04__run {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

.trn-04__switch:checked + .trn-04__run {
  background: var(--accent);
  color: #000;
}

.trn-04__runout {
  display: none;
}

.trn-04__switch:checked + .trn-04__run .trn-04__runin {
  display: none;
}

.trn-04__switch:checked + .trn-04__run .trn-04__runout {
  display: inline;
}

.trn-04__list {
  list-style: none;
  margin: 34px 0 0;
  padding: 0;
  display: grid;
  gap: 30px;
}

.trn-04__row {
  min-inline-size: 0;
  display: grid;
  gap: 10px;
}

.trn-04__meta {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 12px;
  align-items: baseline;
}

.trn-04__name {
  font: 500 15px/1 var(--font-display);
  color: var(--ink);
}

.trn-04__tag {
  font-size: 12px;
  color: var(--muted);
}

.trn-04__track {
  position: relative;
  block-size: 38px;
  border-block-end: 1px solid var(--rule);
}

.trn-04__runner {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  inline-size: 100%;
  transition: transform var(--dur) cubic-bezier(.34, 1.16, .64, 1);
}

.trn-04__chip {
  display: inline-flex;
  align-items: center;
  min-height: 30px;
  padding: 0 12px;
  background: var(--card);
  border: 1px solid var(--accent);
  border-radius: 4px;
  font: 500 12px/1 var(--font-display);
  color: var(--accent);
  box-shadow: 0 0 22px -6px var(--accent);
}

.trn-04:has(#trn-04-run:checked) .trn-04__runner {
  transform: translateX(calc(100% - 78px));
}

@supports (transition-timing-function: linear(0, 1)) {
  .trn-04__runner--bounce {
    transition-timing-function: linear(0, 1.06 38%, 0.94 55%, 1.02 72%, 0.99 86%, 1);
  }

  .trn-04__runner--spring {
    transition-timing-function: linear(0, 0.38 18%, 0.86 38%, 1.12 58%, 1.02 80%, 1);
  }

  .trn-04__runner--step {
    transition-timing-function: linear(0, 0.3 20%, 0.3 45%, 0.72 62%, 0.72 78%, 1);
  }
}

.trn-04__value {
  display: block;
  font: 400 11.5px/1.55 var(--font-display);
  color: var(--muted);
  overflow-wrap: anywhere;
}

.trn-04__gate {
  margin: 34px 0 0;
  padding: 14px 16px;
  border: 1px solid var(--rule);
  border-radius: 6px;
  font-size: 12.5px;
  line-height: 1.65;
  color: var(--muted);
}

.trn-04__gate code {
  font: 400 11.5px/1 var(--font-display);
  color: var(--ink);
  overflow-wrap: anywhere;
}

@media (prefers-reduced-motion: reduce) {
  .trn-04__runner {
    transition: transform 160ms linear, opacity 160ms linear;
  }

  .trn-04__runner--bounce,
    .trn-04__runner--spring,
    .trn-04__runner--step {
    transition-timing-function: linear;
  }

  .trn-04:has(#trn-04-run:checked) .trn-04__runner {
    transform: translateX(calc(100% - 78px));
  }
}

@media (prefers-color-scheme: light) {
  .trn-04 {
    --page: #fbfdfb;
    --card: #ffffff;
    --ink: #0a1410;
    --muted: #5c6b62;
    --rule: #dde5e0;
    --accent: #0f7d43;
  }

  .trn-04__chip {
    box-shadow: 0 2px 10px -4px rgba(15, 125, 67, 0.5);
  }

  .trn-04__switch:checked + .trn-04__run {
    color: #fbfdfb;
  }
}

[data-theme="dark"] .trn-04 {
  --page: #000000;
  --card: #08090b;
  --ink: #f2fff5;
  --muted: #7c8a80;
  --rule: #1a1d1b;
  --accent: #4dff8f;
}

[data-theme="dark"] .trn-04__chip {
  box-shadow: 0 0 22px -6px var(--accent);
}

[data-theme="dark"] .trn-04__switch:checked + .trn-04__run {
  color: #000;
}

.trn-04__theme {
  position: relative;
  inline-size: 100%;
  min-inline-size: 0;
  display: flex;
  justify-content: flex-end;
  margin: 0 0 14px;
}

.trn-04__themecb {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  clip-path: inset(50%);
  overflow: hidden;
}

.trn-04__themebtn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid currentColor;
  border-radius: 999px;
  cursor: pointer;
  color: var(--muted);
  font: 500 11px/1 ui-monospace, Menlo, monospace;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  transition: color 200ms ease, opacity 200ms ease;
}

.trn-04__themebtn:hover {
  color: var(--ink);
}

.trn-04__themecb:focus-visible + .trn-04__themebtn {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.trn-04__themeico {
  display: grid;
  place-items: center;
  inline-size: 15px;
  block-size: 15px;
}

.trn-04__themeico svg {
  grid-area: 1 / 1;
  transition: opacity 200ms ease;
}

.trn-04__sun {
  opacity: 0.34;
}

.trn-04__moon {
  opacity: 1;
}

.trn-04:has(#trn-04-theme:checked) .trn-04__sun {
  opacity: 1;
}

.trn-04:has(#trn-04-theme:checked) .trn-04__moon {
  opacity: 0.34;
}

@media (prefers-color-scheme: dark) {
  .trn-04:has(#trn-04-theme:checked) {
    --page: #fbfdfb;
    --card: #ffffff;
    --ink: #0a1410;
    --muted: #5c6b62;
    --rule: #dde5e0;
    --accent: #0f7d43;
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__chip {
    box-shadow: 0 2px 10px -4px rgba(15, 125, 67, 0.5);
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__switch:checked + .trn-04__run {
    color: #fbfdfb;
  }
}

@media (prefers-color-scheme: light) {
  .trn-04:has(#trn-04-theme:checked) {
    --page: #000000;
    --card: #08090b;
    --ink: #f2fff5;
    --ink: oklch(0.98 0.02 155);
    --muted: #7c8a80;
    --muted: oklch(0.63 0.02 155);
    --rule: #1a1d1b;
    --accent: #4dff8f;
    --accent: oklch(0.88 0.21 148);
    --dur: 620ms;
    --font-display: ui-monospace, "JetBrains Mono", Menlo, monospace;
    background: var(--page);
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__chip {
    display: inline-flex;
    align-items: center;
    min-height: 30px;
    padding: 0 12px;
    background: var(--card);
    border: 1px solid var(--accent);
    border-radius: 4px;
    font: 500 12px/1 var(--font-display);
    color: var(--accent);
    box-shadow: 0 0 22px -6px var(--accent);
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__switch:checked + .trn-04__run {
    background: var(--accent);
    color: #000;
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__sun {
    opacity: 0.34;
  }

  .trn-04:has(#trn-04-theme:checked) .trn-04__moon {
    opacity: 1;
  }
}
```

How this works

A bezier is one hump, and that is the whole limitation. cubic-bezier() has two control points, so it can start fast, end slow, or overshoot once — but it cannot come back, overshoot again, and settle. Anything that reads as bouncy or springy needs multiple direction changes, which is why bounces used to require @keyframes even when the trigger was a plain state change.

linear() is an easing polyline. linear(0, 1.1 40%, 0.95 60%, 1.02 80%, 1) means: start at 0, reach 1.1 of the total change at 40% of the duration, fall back to 0.95, nudge to 1.02, land on 1. The engine interpolates linearly between stops, and enough stops read as a smooth spring. Values above 1 and below 0 are legal, which is what makes overshoot and undershoot expressible.

Declare the fallback first, then gate the upgrade. An unsupported engine drops the whole transition declaration containing linear(), and a dropped transition means an instant snap — so the bezier version goes in the base rule and the linear() version goes inside @supports (transition-timing-function: linear(0, 1)). Test with a two-stop value; a one-argument linear(0) is not a valid probe.

Stops are cheap but not free. Thirty stops does not cost thirty times as much as three, but generated spring curves often ship far more precision than a 300ms transition can show. Round to two decimals and drop stops until the motion changes — a bounce is usually convincing at five or six.

Make it yours

  • Push the first stop above 1.1 for a harder overshoot; anything past 1.3 starts to read as a glitch at short durations.
  • Add a fourth track with linear(0, 0.25 25%, 0.25 50%, 1) to feel a hold in the middle of a transition.
  • Lengthen --dur to 900ms to see the individual stops as distinct direction changes.
  • Change the accent to retint the tracks, the dots and the run control together.
  • Swap transform: translateX() for scale() to apply the same easings to size instead of position.
  • Keep the bezier fallback but soften it to cubic-bezier(.2,.9,.3,1.2) so unsupported engines still get a hint of overshoot.

Gotchas — read before shipping

  • An engine without linear() discards the entire declaration it appears in, so a transition written only with linear() snaps instantly instead of degrading — always declare a bezier first.
  • @supports (transition-timing-function: linear(0)) is not a valid probe; a single stop parses in engines that do not support the multi-stop syntax.
  • Stops without explicit percentages are distributed evenly, so adding one stop silently retimes every stop after it.
  • An overshoot that exceeds the element's container looks like a clipping bug, not a spring — check the parent's overflow before tuning the curve.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by :has(), oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 113+.

linear() sets the floor (Chrome 113, Safari 17.2, Firefox 112). Below those versions the @supports block is skipped and the cubic-bezier() fallback declared first runs instead — the motion still happens, it just cannot overshoot twice. The in-demo theme toggle is pure CSS and relies on :has(), so an engine without it keeps the operating-system theme instead of flipping.

Techniques used in this demo

Search CodeFronts

Loading…