Back to the library

Icon morph — sun to moon

Variant: Sun to moon

The theme toggle. Eight rays retract into the disc 18ms apart around the dial rather than all at once — eight simultaneous exits read as the sun switching off, a cascade reads as it closing down — and the crescent is bitten out by a masked circle sliding in from the upper right, an occlusion rather than a second icon cross-faded in. The disc travels clay to slate on the same beat: the geometry says a change happened, the colour says which way.

controliconmorphsvg

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>Icon morph — sun to moon</title>
    <style>
      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --round: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        /* Clock — every duration in the document runs off --t. Same beat map as
           the rest of the family: press at 8%, morph 11-19%, return 58-66%. */
        --cycle: 3.6s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-accent: cubic-bezier(0.32, 0.72, 0, 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);
      }

      .chip {
        display: grid;
        place-items: center;
        width: clamp(72px, 20vw, 96px);
        aspect-ratio: 1;
        border-radius: calc(18px * var(--round));
        background: var(--paper);
        box-shadow: inset 0 0 0 1px var(--oat);
        animation: press var(--t) infinite;
      }

      .icon {
        display: block;
        width: 58%;
        height: 58%;
      }

      /* Dash 2.6 (the ray's whole length) then a 6-unit gap, so the pattern
         never wraps back round and leaves a sliver at the far end — a bare
         `stroke-dasharray: 2.6` repeats every 5.2 and does exactly that. */
      .ray {
        fill: none;
        stroke: var(--clay);
        stroke-width: 2;
        stroke-linecap: round;
        stroke-dasharray: 2.6 6;
        animation: retract var(--t) infinite;
        animation-delay: calc(var(--_i) * 0.018s / var(--speed));
      }

      .disc {
        fill: var(--clay);
        animation: warm var(--t) infinite;
      }

      .bite {
        animation: bite var(--t) infinite;
      }

      /* Only ever visible under reduced motion. */
      .pose-b {
        fill: var(--slate);
        opacity: 0;
      }

      @keyframes press {
        0%,
        8% {
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        11% {
          transform: scale(0.955);
          animation-timing-function: var(--ease-out);
        }
        16%,
        55% {
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        58% {
          transform: scale(0.955);
          animation-timing-function: var(--ease-out);
        }
        63%,
        100% {
          transform: scale(1);
        }
      }

      /* Each ray retracts into the disc rather than fading, and they go 18ms
         apart around the dial instead of together: eight simultaneous exits read
         as the sun switching off, a cascade reads as it closing down. The path
         is authored inner point first, so a rising dashoffset eats the outer end
         and the light appears to be pulled in. */
      @keyframes retract {
        0%,
        11% {
          stroke-dashoffset: 0;
          animation-timing-function: var(--ease-out);
        }
        19%,
        58% {
          stroke-dashoffset: 2.8;
          animation-timing-function: var(--ease-out);
        }
        66%,
        100% {
          stroke-dashoffset: 0;
        }
      }

      /* The crescent is bitten out by a masked circle sliding in from the upper
         right — an occlusion, not a second icon cross-faded in, which is why the
         disc keeps one continuous edge the whole way across instead of two
         outlines briefly overlapping. Parked at 9.6 the bite is 13.6 from the
         disc centre, clear of the 12 where the two circles would first touch. */
      @keyframes bite {
        0%,
        11% {
          transform: translate(9.6px, -9.6px);
          animation-timing-function: var(--ease-accent);
        }
        19%,
        58% {
          transform: translate(3.4px, -3.4px);
          animation-timing-function: var(--ease-accent);
        }
        66%,
        100% {
          transform: translate(9.6px, -9.6px);
        }
      }

      /* Clay to slate on the same beat. This is the part that actually says
         which theme you are heading for — the geometry says a change happened,
         the colour says which way. */
      @keyframes warm {
        0%,
        11% {
          fill: var(--clay);
          animation-timing-function: ease;
        }
        19%,
        58% {
          fill: var(--slate);
          animation-timing-function: ease;
        }
        66%,
        100% {
          fill: var(--clay);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* Gentler, not absent. The rays stop retracting and the bite stays
           parked; the two poses cross-fade on the beats the morph was using, so
           the colour change — the part carrying the meaning — survives intact. */
        .chip {
          animation: none;
        }

        .ray {
          animation: none;
          stroke-dashoffset: 0;
        }

        .disc {
          animation: none;
        }

        .bite {
          animation: none;
          transform: translate(9.6px, -9.6px);
        }

        .pose-a {
          animation: cross-out calc(var(--t) * 1.8) ease infinite;
        }

        .pose-b {
          animation: cross-in calc(var(--t) * 1.8) ease infinite;
        }

        @keyframes cross-out {
          0%,
          11% {
            opacity: 1;
          }
          19%,
          58% {
            opacity: 0;
          }
          66%,
          100% {
            opacity: 1;
          }
        }

        @keyframes cross-in {
          0%,
          11% {
            opacity: 0;
          }
          19%,
          58% {
            opacity: 1;
          }
          66%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="chip" role="img" aria-label="A sun icon closing down into a crescent moon">
      <svg class="icon" viewBox="0 0 24 24">
        <mask id="bite-mask" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
          <rect x="0" y="0" width="24" height="24" fill="#fff" />
          <circle class="bite" cx="12" cy="12" r="6.6" fill="#000" />
        </mask>
        <g class="pose-a">
          <g class="rays">
            <line class="ray" style="--_i: 0" x1="12" y1="4.2" x2="12" y2="1.6" />
            <line
              class="ray"
              style="--_i: 1"
              x1="12"
              y1="4.2"
              x2="12"
              y2="1.6"
              transform="rotate(45 12 12)"
            />
            <line
              class="ray"
              style="--_i: 2"
              x1="12"
              y1="4.2"
              x2="12"
              y2="1.6"
              transform="rotate(90 12 12)"
            />
            <line
              class="ray"
              style="--_i: 3"
              x1="12"
              y1="4.2"
              x2="12"
              y2="1.6"
              transform="rotate(135 12 12)"
            />
            <line
              class="ray"
              style="--_i: 4"
              x1="12"
              y1="4.2"
              x2="12"
              y2="1.6"
              transform="rotate(180 12 12)"
            />
            <line
              class="ray"
              style="--_i: 5"
              x1="12"
              y1="4.2"
              x2="12"
              y2="1.6"
              transform="rotate(225 12 12)"
            />
            <line
              class="ray"
              style="--_i: 6"
              x1="12"
              y1="4.2"
              x2="12"
              y2="1.6"
              transform="rotate(270 12 12)"
            />
            <line
              class="ray"
              style="--_i: 7"
              x1="12"
              y1="4.2"
              x2="12"
              y2="1.6"
              transform="rotate(315 12 12)"
            />
          </g>
          <circle class="disc" cx="12" cy="12" r="5.4" mask="url(#bite-mask)" />
        </g>
        <!-- The static crescent: the same two circles, resolved by hand at their
             two intersection points so the reduced-motion pose needs no mask. -->
        <path
          class="pose-b"
          d="M16.41 15.12 A5.4 5.4 0 1 1 8.88 7.6 A6.6 6.6 0 0 0 16.41 15.12 Z"
        />
      </svg>
    </div>
  </body>
</html>

The variant family

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