Wiper

No dependencies
Code format
React + Tailwind
import type { CSSProperties } from 'react';

const css = `
  /* Out across the glass, back home, then a rest while the rain gathers. */
  @keyframes loader-wiper-sweep {
    0% { transform: rotate(-172deg); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
    34% { transform: rotate(-8deg); }
    40% { transform: rotate(-8deg); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
    74%, 100% { transform: rotate(-172deg); }
  }
  /* Each drop is timed so 0% is the moment the blade reaches it on the way out. */
  @keyframes loader-wiper-drop {
    0% { opacity: 1; transform: scale(1); }
    1.5%, 72% { opacity: 0; transform: scale(0.2); }
    76% { opacity: 1; transform: scale(1.25); }
    80%, 100% { opacity: 1; transform: scale(1); }
  }
  .loader-wiper { position: relative; width: 124px; height: 97px; }
  .loader-wiper svg { width: 100%; height: 100%; overflow: visible; }
  .loader-wiper__glass { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#cbced3, #393c41); stroke-width: 1.2; stroke-linejoin: round; }
  .loader-wiper__sheen { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 4; stroke-linecap: round; }
  .loader-wiper__dash { fill: light-dark(#dbdde1, #2e3035); }
  .loader-wiper__drop { transform-box: fill-box; transform-origin: center; animation: loader-wiper-drop 2.6s linear infinite var(--d); }
  .loader-wiper__water { fill: light-dark(#5f626a, #a7abb2); opacity: 0.85; }
  .loader-wiper__glint { fill: light-dark(#373a40, #f1f2f3); }
  .loader-wiper__arm { transform-origin: 50px 66px; animation: loader-wiper-sweep 2.6s infinite; }
  .loader-wiper__rod { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.8; stroke-linecap: round; }
  .loader-wiper__blade { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2.8; stroke-linecap: round; }
  .loader-wiper__pivot { fill: light-dark(#6d7179, #7d8087); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
  @media (prefers-reduced-motion: reduce) {
    .loader-wiper__arm, .loader-wiper__drop { animation: none; }
    .loader-wiper__arm { transform: rotate(-172deg); }
  }
`;

// t: when, as a share of the cycle, the blade passes the drop on its way out.
const DROPS = [
  { x: 16.5, y: 52.4, r: 1.7, t: 0.072 },
  { x: 29.8, y: 41.4, r: 1.9, t: 0.122 },
  { x: 37.3, y: 36.3, r: 2.1, t: 0.143 },
  { x: 53.1, y: 35.4, r: 2, t: 0.177 },
  { x: 64, y: 51, r: 1.7, t: 0.223 },
  { x: 64.7, y: 38.7, r: 1.4, t: 0.203 },
  { x: 78.3, y: 47.9, r: 1.5, t: 0.246 },
  { x: 42.8, y: 48.9, r: 1.7, t: 0.143 },
  { x: 31.6, y: 55.4, r: 1.9, t: 0.089 },
  { x: 46.5, y: 31.3, r: 2, t: 0.163 },
  { x: 70.8, y: 53.9, r: 2, t: 0.25 },
  { x: 54, y: 48.3, r: 2.2, t: 0.184 },
  { x: 61.6, y: 31.7, r: 1.4, t: 0.192 },
  { x: 23.4, y: 54.6, r: 2.3, t: 0.075 },
  { x: 75, y: 38.1, r: 1.5, t: 0.221 },
];

export function WiperLoader() {
  return (
    <>
      <style>
        {css}
      </style>
      <div className="loader-wiper" role="status" aria-label="Loading">
        <svg viewBox="0 0 100 78" aria-hidden="true">
          <path className="loader-wiper__glass" d="M8 66L16 16Q18 9 25 9H75Q82 9 84 16L92 66Z" />
          <path className="loader-wiper__sheen" d="M70 16L58 40M80 22L74 34" />
          {DROPS.map((p) => (
            <g key={`${p.x}-${p.y}`} className="loader-wiper__drop" style={{ '--d': `${((p.t - 1) * 2.6).toFixed(3)}s` } as CSSProperties}>
              <circle className="loader-wiper__water" cx={p.x} cy={p.y} r={p.r} />
              <circle className="loader-wiper__glint" cx={p.x - p.r * 0.35} cy={p.y - p.r * 0.35} r={p.r * 0.32} />
            </g>
          ))}
          <g className="loader-wiper__arm">
            <line className="loader-wiper__rod" x1="50" y1="66" x2="86" y2="66" />
            <line className="loader-wiper__blade" x1="62" y1="66" x2="89" y2="66" />
          </g>
          <rect className="loader-wiper__dash" x="2" y="66" width="96" height="9" rx="3" />
          <circle className="loader-wiper__pivot" cx="50" cy="66" r="3.2" />
        </svg>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

Rain gathers on a windscreen, the wiper sweeps across and clears every drop it passes, swings home, and the rain starts to gather again.

Dependencies
None
Difficulty
Advanced
Triggers
continuous, loop, delay stagger
Techniques
keyframes, css variables, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the wiper rests and the rain stays.

More loaders

See all
Yo-yo
Ampersand
Beacon
Brew