Back to the library

Emphasis — circled by hand

Variant: Circled by hand

The loudest of the four and the one to use once on a page: a loop drawn around the phrase in 528ms that closes past where it started, because a hand circling something overlaps its own opening stroke. The overshoot is drawn into the geometry rather than timed, so it costs nothing to keep in step with the family. Fitting a hand mark to a word of unknown aspect is the real problem here, and it takes two things together — preserveAspectRatio="none" to stretch the loop to whatever box the phrase occupies, and vector-effect="non-scaling-stroke" to stop the ink fattening on one axis while it stretches. Nearly symmetric easing, the one place an ease-in half belongs in this library: a hand leaves rest and returns to rest inside a single loop.

textemphasissvgline-drawing

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 — circled by hand</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%; this one spends
           528ms of that window drawing, twice what the underline takes, because
           it is travelling four sides instead of one. */
        --cycle: 4.4s;
        --t: calc(var(--cycle) / var(--speed));

        /* Nearly symmetric, and the one place in this family an ease-in half is
           correct: a hand leaves rest and returns to rest inside a single
           closing loop, and it does not coast to a crawl halfway round. */
        --ease-accent: cubic-bezier(0.5, 0, 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;
      }

      /* A span rather than the SVG itself takes the insets, because an
         absolutely positioned replaced element sizes from its intrinsic ratio
         and not from its offsets — the wrapper gives the percentages below
         something real to resolve against. The loop deliberately overlaps the
         lines above and below: a mark made over finished prose sits on top of
         it rather than inside a reserved slot. */
      .mark {
        position: absolute;
        inset: -0.5em -0.58em;
        pointer-events: none;
      }

      .mark svg {
        display: block;
        width: 100%;
        height: 100%;
        overflow: visible;
      }

      /* preserveAspectRatio="none" stretches the loop to whatever box the
         phrase happens to occupy, and vector-effect holds the ink at a constant
         weight while it does — together they are the only way to fit a hand
         mark to a word of unknown aspect without the stroke fattening on one
         axis. The travel starts at 103 rather than 100 so the dash's round cap
         is parked clear of the path: at exactly 100 it still paints, and an
         absent stroke shows as a speck on the left edge. */
      .loop {
        fill: none;
        stroke: var(--clay);
        stroke-width: 2;
        stroke-linecap: round;
        stroke-dasharray: 100 200;
        animation: circle var(--t) infinite;
      }

      /* The path closes past where it started — the overshoot is drawn into the
         geometry rather than timed, which is why it costs nothing to keep in
         step with the rest of the family. It leaves as a fade: ink does not
         un-draw, and rewinding the loop would read as the annotation being
         taken back rather than the page moving on. The offset rearms at 88.1%
         under zero opacity. */
      @keyframes circle {
        0%,
        14% {
          stroke-dashoffset: 103;
          opacity: 1;
          animation-timing-function: var(--ease-accent);
        }
        26%,
        78% {
          stroke-dashoffset: 0;
          opacity: 1;
          animation-timing-function: ease;
        }
        88% {
          stroke-dashoffset: 0;
          opacity: 0;
        }
        88.1%,
        100% {
          stroke-dashoffset: 103;
          opacity: 0;
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The drawing is the travel, so it goes. The loop is closed from the
           start and only its opacity moves, on the same stretched clock and the
           same beats the other three siblings use. */
        .loop {
          stroke-dashoffset: 0;
          animation-name: circle-flat;
          animation-duration: calc(var(--t) * 1.7);
        }

        @keyframes circle-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 class="mark" aria-hidden="true"
              ><svg viewBox="0 0 120 44" preserveAspectRatio="none">
                <path
                  class="loop"
                  pathLength="100"
                  vector-effect="non-scaling-stroke"
                  d="M 8 20 C 10 8 34 2 62 2 C 92 2 117 8 116 21 C 115 33 92 42 60 42 C 30 42 5 36 5 22 C 4.6 13 11 6 24 3"
                /></svg></span></span
          >, so the seek
        </p>
        <p class="line">still pays for every fetch.</p>
      </div>
    </div>
  </body>
</html>