Tape Measure

No dependencies
Code format
React + Tailwind
const T = 3;
const BLADE = 150;

const css = `
  .loader-tape-measure { --shell: light-dark(#50535b, #393c41); --grip: light-dark(#373a40, #232529); --blade: light-dark(#ebedf0, #e5e6e9); --tick: light-dark(#373a40, #121417); --metal: light-dark(#6d7179, #a7abb2); position: relative; width: 230px; height: 128px; }
  /* The case and everything it holds kick back together when the blade slams home. */
  .loader-tape-measure__body { position: absolute; left: 2px; top: 22px; width: 80px; height: 80px; transform-origin: 40px 44px; animation: loader-tape-measure-kick ${T}s infinite; }
  .loader-tape-measure__shell { position: absolute; inset: 0; z-index: 2; border-radius: 22px 22px 22px 14px; background: linear-gradient(150deg, color-mix(in oklab, var(--shell) 70%, light-dark(#373a40, #f1f2f3)), var(--shell) 45%, color-mix(in oklab, var(--shell) 75%, light-dark(#fbfcfd, #121417))); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 25%, transparent), 0 8px 14px -6px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .loader-tape-measure__shell::before { content: ''; position: absolute; inset: 9px 9px 11px; border-radius: 16px; background: repeating-linear-gradient(0deg, var(--grip) 0 2px, color-mix(in oklab, var(--grip) 80%, light-dark(#6d7179, #7d8087)) 2px 4px); }
  .loader-tape-measure__badge { position: absolute; left: 50%; top: 46%; z-index: 1; display: grid; place-items: center; width: 30px; height: 30px; margin: -15px; border-radius: 50%; background: radial-gradient(circle at 40% 35%, color-mix(in oklab, var(--metal) 50%, light-dark(#373a40, #f1f2f3)), var(--metal) 70%); color: light-dark(#f3f4f7, #1a1c1f); font: 700 9px/1 'Arial Narrow', 'Helvetica Neue', sans-serif; letter-spacing: 0.04em; }
  .loader-tape-measure__lock { position: absolute; left: 24px; top: -5px; width: 22px; height: 8px; border-radius: 3px 3px 0 0; background: var(--metal); }
  /* The blade lives in a track that starts at the mouth; inside the case it is hidden. */
  .loader-tape-measure__track { position: absolute; left: 74px; top: 58px; z-index: 1; width: ${BLADE}px; height: 14px; clip-path: inset(-14px -24px -14px 0); transform-origin: 0 50%; animation: loader-tape-measure-sag ${T}s infinite; }
  .loader-tape-measure__blade { position: absolute; inset: 0; border-radius: 0 1px 1px 0; background: repeating-linear-gradient(90deg, var(--tick) 0 1.2px, transparent 1.2px 25px) 0 0 / 100% 7px no-repeat, repeating-linear-gradient(90deg, var(--tick) 0 0.8px, transparent 0.8px 5px) 0 0 / 100% 3.5px no-repeat, linear-gradient(transparent 85%, color-mix(in oklab, var(--tick) 25%, transparent) 85%), var(--blade); box-shadow: 0 2px 3px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); animation: loader-tape-measure-run ${T}s infinite; }
  .loader-tape-measure__num { position: absolute; top: 5px; color: var(--tick); font: 700 7px/1 'Arial Narrow', 'Helvetica Neue', sans-serif; }
  /* The hook: a bent tab that sits against the mouth when the blade is in. */
  .loader-tape-measure__hook { position: absolute; right: -4px; top: -5px; width: 5px; height: 24px; border-radius: 1px 2px 2px 1px; background: linear-gradient(90deg, var(--metal), color-mix(in oklab, var(--metal) 65%, light-dark(#fbfcfd, #121417))); }
  .loader-tape-measure__hook::before { content: ''; position: absolute; left: -2px; top: 9px; width: 3px; height: 3px; border-radius: 50%; background: color-mix(in oklab, var(--metal) 50%, light-dark(#fbfcfd, #121417)); }
  @keyframes loader-tape-measure-run {
    0%, 8% { transform: translateX(${-BLADE + 6}px); animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1); }
    42%, 68% { transform: translateX(0); animation-timing-function: cubic-bezier(0.65, 0, 0.95, 0.55); }
    75%, 100% { transform: translateX(${-BLADE + 6}px); }
  }
  @keyframes loader-tape-measure-sag {
    0%, 40% { transform: rotate(0deg); }
    47% { transform: rotate(5deg); }
    52% { transform: rotate(2.2deg); }
    57% { transform: rotate(4.2deg); }
    62%, 68% { transform: rotate(3.5deg); animation-timing-function: ease-in; }
    75%, 100% { transform: rotate(0deg); }
  }
  @keyframes loader-tape-measure-kick {
    0%, 75% { transform: none; }
    78% { transform: translateX(-4px) rotate(-8deg); }
    82% { transform: translateX(1px) rotate(3deg); }
    86% { transform: rotate(-1.2deg); }
    90%, 100% { transform: none; }
  }
  @media (prefers-reduced-motion: reduce) {
    .loader-tape-measure *, .loader-tape-measure__body { animation: none !important; }
    .loader-tape-measure__blade { transform: translateX(-40px); }
  }
`;

export function Preview() {
  return (
    <>
      <style href="kit-loaders-tape-measure" precedence="kit">
        {css}
      </style>
      <div className="loader-tape-measure" role="status" aria-label="Loading">
        <span className="loader-tape-measure__body" aria-hidden="true">
          <span className="loader-tape-measure__lock" />
          <span className="loader-tape-measure__track">
            <span className="loader-tape-measure__blade">
              {[1, 2, 3, 4, 5].map((n) => (
                <span key={n} className="loader-tape-measure__num" style={{ left: n * 25 - 7 }}>
                  {n}
                </span>
              ))}
              <span className="loader-tape-measure__hook" />
            </span>
          </span>
          <span className="loader-tape-measure__shell">
            <span className="loader-tape-measure__badge">5 m</span>
          </span>
        </span>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/loader-tape-measure
Source on GitHub

About this component

A tape measure runs its blade out, sags and wobbles at full length, then snaps back so the case jumps.

Dependencies
None
Difficulty
Intermediate
Triggers
continuous, loop, spring
Techniques
keyframes, clip path, transform only
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the blade rests most of the way out.

More loaders

See all
Triad
Twist
Typewriter
Worm