Back to the library

Emphasis — rule drawn under

Variant: Rule drawn under

A 2px rule growing left to right under the phrase in 308ms — the quietest mark in the family that is still a mark, and the right answer for a glossary term or a footnote anchor seen many times a page. It eases out because a rule under a word is arriving at the end of that word rather than passing through it; the one thing an underline must never look like is a stroke that kept going. It leaves with both edges travelling right rather than retreating, so the mark reads as wiped off instead of un-drawn, and the reset is invisible because the rule has zero width at both ends of it. Every dimension is in em, so it stays a hairline on a phone instead of a bar.

textemphasisclip-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>Emphasis — rule drawn under</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.4s, shared across the emphasis family. The mark begins at
           14% in every sibling and is fully released by 88%. */
        --cycle: 4.4s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);

        --_read: color-mix(in srgb, var(--slate) 78%, transparent);
      }

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

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        padding: clamp(12px, 3vw, 28px);
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
      }

      .doc {
        width: min(360px, 92vw);
        padding: clamp(14px, 3.4vw, 22px) clamp(15px, 3.6vw, 24px);
        border: 1px solid var(--oat);
        border-radius: calc(14px * var(--round));
        background: var(--paper);
        color: var(--_read);
      }

      .eyebrow {
        font-size: clamp(11.5px, 2.5vw, 13px);
        font-weight: 600;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        opacity: 0.55;
      }

      .copy {
        margin-top: clamp(7px, 1.4vw, 11px);
        display: grid;
        justify-items: start;
      }

      .line {
        white-space: nowrap;
        font-size: clamp(13px, 3.1vw, 18px);
        line-height: 1.5;
        letter-spacing: -0.005em;
      }

      .term {
        position: relative;
        color: var(--slate);
        font-weight: 500;
      }

      /* Every dimension is in em, so the rule keeps its proportion to the type
         at all four stage widths rather than turning into a heavy bar on a
         phone. The 2px floor is there because a hairline under 13px copy
         disappears on a low-density display. */
      .term::after {
        content: '';
        position: absolute;
        left: -0.06em;
        right: -0.06em;
        bottom: -0.17em;
        height: max(2px, 0.085em);
        border-radius: 999px;
        background: var(--clay);
        clip-path: inset(0 100% 0 0);
        animation: rule var(--t) infinite;
      }

      /* 308ms to draw, ease-out. A rule under a word is arriving at the end of
         that word, not passing through it — the one thing an underline must
         never look like is a stroke that kept going. It leaves the way it came
         in, both edges travelling right, so the mark reads as being wiped off
         rather than un-drawn; the reset at 84.6% is invisible because the rule
         has zero width at both ends of it. */
      @keyframes rule {
        0%,
        14% {
          clip-path: inset(0 100% 0 0);
          animation-timing-function: var(--ease-out);
        }
        21%,
        78% {
          clip-path: inset(0 0 0 0);
          animation-timing-function: var(--ease-out);
        }
        84.5% {
          clip-path: inset(0 0 0 100%);
        }
        84.6%,
        100% {
          clip-path: inset(0 100% 0 0);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The travelling edge is the thing being removed. The rule is present
           at full width the whole time and only its opacity moves, which keeps
           the mark saying the same thing on a slower clock. */
        .term::after {
          clip-path: none;
          animation-name: rule-flat;
          animation-duration: calc(var(--t) * 1.7);
        }

        @keyframes rule-flat {
          0%,
          14% {
            opacity: 0;
            animation-timing-function: ease;
          }
          24%,
          78% {
            opacity: 1;
            animation-timing-function: ease;
          }
          90%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="doc">
      <p class="eyebrow">Planner notes</p>
      <div class="copy">
        <p class="line">Rows come back in index order,</p>
        <p class="line"><span class="term">not heap order</span>, so the seek</p>
        <p class="line">still pays for every fetch.</p>
      </div>
    </div>
  </body>
</html>