Back to the library

Toggle — commit wipe

Variant: Wipe

The thumb is gone; the commit is a clip-path edge crossing the pill in 180ms, with the label rolling from OFF to ON a hundred milliseconds behind it. Anything slower and the wipe stops reading as a decision and starts reading as a progress bar. The sharpest of the three — reach for it on a labelled toggle where the word, not a moving part, is what the user is reading.

controlclip-path

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Toggle — commit wipe</title>
    <style>
      :root {
        --ivory: #faf9f5;
        --slate: #141413;
        --clay: #d97757;
        --oat: #e3dacc;
        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      .pill {
        position: relative;
        display: grid;
        place-items: center;
        width: 132px;
        height: 48px;
        border-radius: 24px;
        background: var(--oat);
        overflow: hidden;
        animation: pill-press 4.2s infinite;
      }

      /* No thumb to follow — the commit is the colour crossing the control.
         clip-path keeps that on the compositor; an animated width would not,
         and would drag the label along with it. 180ms edge to edge: slower and
         the wipe stops reading as a decision and starts reading as progress. */
      .on-layer {
        position: absolute;
        inset: 0;
        background: var(--clay);
        clip-path: inset(0 100% 0 0);
        animation: wipe 4.2s infinite;
      }

      .window {
        position: relative;
        height: 18px;
        overflow: hidden;
      }

      /* A grid rather than two stacked blocks: it lays out identically here, and
         it is what lets the reduced-motion branch put both labels in one cell
         without re-plumbing the layout underneath them. */
      .stack {
        display: grid;
        animation: roll 4.2s infinite;
      }

      .row {
        height: 18px;
        line-height: 18px;
        font-size: 12px;
        font-weight: 600;
        letter-spacing: 0.16em;
        /* Letter-spacing adds a trailing gap after the last glyph, which pushes
           centred caps left by half of it. Indent by one step to put it back. */
        text-indent: 0.16em;
        text-align: center;
      }

      .row.on {
        color: var(--ivory);
      }

      @keyframes wipe {
        0%,
        12.4% {
          clip-path: inset(0 100% 0 0);
          animation-timing-function: var(--ease-out);
        }
        16.7%,
        56.2% {
          clip-path: inset(0 0 0 0);
          animation-timing-function: var(--ease-out);
        }
        60.5%,
        100% {
          clip-path: inset(0 100% 0 0);
        }
      }

      /* The label rolls 100ms behind the leading edge on the way in and 60ms
         ahead of it on the way out, so the word is never stranded on the wrong
         background for longer than a frame or two. */
      @keyframes roll {
        0%,
        14.8% {
          transform: translateY(0);
          animation-timing-function: var(--ease-out);
        }
        18.6%,
        54.8% {
          transform: translateY(-18px);
          animation-timing-function: var(--ease-out);
        }
        58.6%,
        100% {
          transform: translateY(0);
        }
      }

      @keyframes pill-press {
        0%,
        9.5% {
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        12.4% {
          transform: scale(0.97);
          animation-timing-function: var(--ease-out);
        }
        16.7%,
        52.4% {
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        55.2% {
          transform: scale(0.97);
          animation-timing-function: var(--ease-out);
        }
        59.5%,
        100% {
          transform: scale(1);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        .pill {
          animation: none;
        }

        .on-layer {
          clip-path: none;
          opacity: 0;
          animation: fill-fade 4.2s ease infinite;
        }

        .stack {
          animation: none;
          transform: translateY(0);
        }

        /* The travel is removed by collapsing the two rows into the one grid
           cell they already share a track with — no repositioning, no change of
           containing block, nothing for the surrounding layout to notice. */
        .row {
          grid-area: 1 / 1;
        }

        /* Both halves of the crossfade, not just the arriving one: stacked in a
           single cell, an ON that only fades in leaves OFF legible underneath it
           and the label reads as both states at once. */
        .row.off {
          animation: label-fade-out 4.2s ease infinite;
        }

        .row.on {
          opacity: 0;
          animation: label-fade 4.2s ease infinite;
        }

        @keyframes fill-fade {
          0%,
          12.4% {
            opacity: 0;
          }
          16.7%,
          56.2% {
            opacity: 1;
          }
          60.5%,
          100% {
            opacity: 0;
          }
        }

        @keyframes label-fade {
          0%,
          13.5% {
            opacity: 0;
          }
          17.5%,
          55.5% {
            opacity: 1;
          }
          59.5%,
          100% {
            opacity: 0;
          }
        }

        @keyframes label-fade-out {
          0%,
          13.5% {
            opacity: 1;
          }
          17.5%,
          55.5% {
            opacity: 0;
          }
          59.5%,
          100% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="pill" role="img" aria-label="Toggle committing from off to on">
      <div class="on-layer"></div>
      <div class="window">
        <div class="stack">
          <div class="row off">OFF</div>
          <div class="row on">ON</div>
        </div>
      </div>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.