Back to the library

Badge — trend delta

Variant: Trend delta

The green-up, red-down chip on a dashboard tile, where the flip is the event: the whole chip turns on X to swap arrow, value and colour at once, so the direction change lands as one motion instead of three properties transitioning apart. It rotates one way the entire loop — up to down, then on round to up again — so the return is another flip and never a rewind, and the down face is pre-turned in markup so a single continuous rotation brings it to the viewer at the halfway point. Ease-in-out, because a read flipping is a morph on screen and not an entrance; a spring here would read as the number being unsure of itself. The arrows are borders rather than glyphs so they carry no font metrics and sit on the optical centre, and under reduced motion the turn becomes a crossfade on the same schedule — the colour and arrow still swap, because that swap is the meaning.

statusbadgedata

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>Badge — trend delta</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 — 4.6s: a long read of each state and two half-second flips
           between them. The chip turns one direction the whole way round, so the
           reset back to the up face is another flip, never a rewind. */
        --cycle: 4.6s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 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 {
        position: relative;
        width: 92px;
        height: 34px;
        perspective: 300px; /* low, so the turn has real depth at this size */
      }

      .flip {
        position: absolute;
        inset: 0;
        transform-style: preserve-3d;
        /* A morph on screen, so ease-in-out — a directional read flipping is not
           an entrance, and a spring here would read as the number being unsure. */
        animation: turn var(--t) var(--ease-in-out) infinite;
      }

      .face {
        position: absolute;
        inset: 0;
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 6px;
        border-radius: calc(8px * var(--round));
        font-size: 14px;
        font-weight: 700;
        font-variant-numeric: tabular-nums;
        letter-spacing: -0.01em;
        backface-visibility: hidden;
      }

      .up {
        background: color-mix(in srgb, var(--olive) 15%, var(--paper));
        color: var(--olive);
        box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--olive) 30%, transparent);
      }

      /* The down face is pre-turned in markup, so the single continuous rotation
         brings it to the viewer at the halfway point with no second timeline. */
      .down {
        background: color-mix(in srgb, var(--rust) 15%, var(--paper));
        color: var(--rust);
        box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--rust) 30%, transparent);
        transform: rotateX(180deg);
      }

      /* Arrows are borders rather than glyphs, so they inherit no font metrics
         and sit on the optical centre; currentColor ties each to its face. */
      .arrow {
        width: 0;
        height: 0;
        border-left: 5px solid transparent;
        border-right: 5px solid transparent;
      }
      .up .arrow {
        border-bottom: 7px solid currentColor;
      }
      .down .arrow {
        border-top: 7px solid currentColor;
      }

      @keyframes turn {
        0%,
        40% {
          transform: rotateX(0deg);
        }
        48%,
        88% {
          transform: rotateX(180deg);
        }
        96%,
        100% {
          transform: rotateX(360deg);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The turn is real depth motion, so it goes — but the state change is
           the meaning, so it stays as a crossfade on the same schedule. The
           faces come out of 3D and stack flat; colour and arrow still swap. */
        .flip {
          animation: none;
          transform: none;
          transform-style: flat;
        }
        .face {
          backface-visibility: visible;
        }
        .down {
          transform: none;
        }
        .up {
          animation: fade-up calc(var(--t) * 1.4) var(--ease-in-out) infinite;
        }
        .down {
          animation: fade-down calc(var(--t) * 1.4) var(--ease-in-out) infinite;
        }

        @keyframes fade-up {
          0%,
          40% {
            opacity: 1;
          }
          50%,
          90% {
            opacity: 0;
          }
          100% {
            opacity: 1;
          }
        }
        @keyframes fade-down {
          0%,
          40% {
            opacity: 0;
          }
          50%,
          90% {
            opacity: 1;
          }
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div
      class="chip"
      role="img"
      aria-label="A trend chip flipping between an up state at plus 2.4 percent and a down state at minus 1.1 percent"
    >
      <div class="flip">
        <span class="face up"><span class="arrow"></span>2.4%</span>
        <span class="face down"><span class="arrow"></span>1.1%</span>
      </div>
    </div>
  </body>
</html>

The variant family

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