Popcorn

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

const css = `
  /* Each kernel bursts out of the pile, puffs open on the way up and falls back in. */
  @keyframes loader-popcorn-x { from { transform: translateX(0); } to { transform: translateX(var(--dx)); } }
  @keyframes loader-popcorn-y {
    0% { transform: translateY(0) scale(0.35); opacity: 0; animation-timing-function: cubic-bezier(0.33, 0.67, 0.67, 1); }
    6% { opacity: 1; }
    12% { transform: translateY(calc(var(--h) * 0.45)) scale(1.15); }
    42% { transform: translateY(var(--h)) scale(1); animation-timing-function: cubic-bezier(0.33, 0, 0.67, 0.33); }
    84% { transform: translateY(2px) scale(1); opacity: 1; }
    92%, 100% { transform: translateY(6px) scale(1); opacity: 0; }
  }
  @keyframes loader-popcorn-spin { from { transform: rotate(0deg); } to { transform: rotate(var(--rot)); } }
  @keyframes loader-popcorn-jiggle { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-1px); } }
  .loader-popcorn { position: relative; width: 100px; height: 115px; }
  .loader-popcorn svg { width: 100%; height: 100%; overflow: visible; }
  .loader-popcorn__puff { fill: light-dark(#ebedf0, #e5e6e9); stroke: light-dark(#cbced3, #a7abb2); stroke-width: 0.8; }
  .loader-popcorn__x { animation: loader-popcorn-x var(--dur) linear infinite var(--d); }
  .loader-popcorn__y { opacity: 0; animation: loader-popcorn-y var(--dur) linear infinite var(--d); }
  .loader-popcorn__spin { animation: loader-popcorn-spin var(--dur) linear infinite var(--d); }
  .loader-popcorn__pile { animation: loader-popcorn-jiggle 0.45s ease-in-out infinite; }
  .loader-popcorn__bucket { fill: light-dark(#cbced3, #393c41); }
  .loader-popcorn__stripe { fill: light-dark(#5f626a, #a7abb2); }
  .loader-popcorn__rim { fill: light-dark(#6d7179, #7d8087); }
  @media (prefers-reduced-motion: reduce) {
    .loader-popcorn__x, .loader-popcorn__y, .loader-popcorn__spin, .loader-popcorn__pile { animation: none; }
  }
`;

// One popped kernel: a few overlapping puffs.
function Puff() {
  return (
    <>
      <circle className="loader-popcorn__puff" cx="-2.6" cy="-1.2" r="2.5" />
      <circle className="loader-popcorn__puff" cx="2.8" cy="-1.5" r="2.7" />
      <circle className="loader-popcorn__puff" cx="0.6" cy="-3.4" r="2.3" />
      <circle className="loader-popcorn__puff" cx="0" cy="0.2" r="3.1" />
    </>
  );
}

const KERNELS = [
  { x: 36, dx: -16, h: -34, rot: -320, dur: 1.5, d: 0 },
  { x: 48, dx: 12, h: -42, rot: 280, dur: 1.7, d: -0.55 },
  { x: 42, dx: 4, h: -28, rot: 200, dur: 1.4, d: -1.0 },
  { x: 54, dx: -8, h: -38, rot: -240, dur: 1.8, d: -0.3 },
  { x: 32, dx: 10, h: -30, rot: 300, dur: 1.6, d: -1.25 },
  { x: 50, dx: 16, h: -26, rot: -200, dur: 1.5, d: -0.8 },
];

// The heap showing over the rim.
const PILE = [
  [22, 49, 0.9],
  [30, 44, 1.1],
  [40, 41, 1.15],
  [50, 42, 1.1],
  [60, 46, 1],
  [27, 51, 0.8],
  [36, 48, 1],
  [46, 47, 1],
  [56, 49, 0.9],
  [64, 51, 0.8],
];

export function PopcornLoader() {
  return (
    <>
      <style>
        {css}
      </style>
      <div className="loader-popcorn" role="status" aria-label="Loading">
        <svg viewBox="0 0 84 96" aria-hidden="true">
          {KERNELS.map((k, i) => (
            <g key={i} transform={`translate(${k.x} 44)`}>
              <g className="loader-popcorn__x" style={{ '--dx': `${k.dx}px`, '--dur': `${k.dur}s`, '--d': `${k.d}s` } as CSSProperties}>
                <g className="loader-popcorn__y" style={{ '--h': `${k.h}px`, '--dur': `${k.dur}s`, '--d': `${k.d}s` } as CSSProperties}>
                  <g className="loader-popcorn__spin" style={{ '--rot': `${k.rot}deg`, '--dur': `${k.dur}s`, '--d': `${k.d}s` } as CSSProperties}>
                    <Puff />
                  </g>
                </g>
              </g>
            </g>
          ))}
          <g className="loader-popcorn__pile">
            {PILE.map(([x, y, s]) => (
              <g key={`${x}-${y}`} transform={`translate(${x} ${y}) scale(${s})`}>
                <Puff />
              </g>
            ))}
          </g>
          <path className="loader-popcorn__bucket" d="M18 52H66L60 92H24Z" />
          {[1, 3, 5].map((i) => (
            <path key={i} className="loader-popcorn__stripe" d={`M${18 + 8 * i} 52H${26 + 8 * i}L${30 + 6 * i} 92H${24 + 6 * i}Z`} />
          ))}
          <rect className="loader-popcorn__rim" x="15" y="50" width="54" height="4" rx="1.5" />
        </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-popcorn
Source on GitHub

About this component

A striped cinema bucket of popcorn where kernels keep popping out of the pile, puffing open on the way up and tumbling back in.

Dependencies
None
Difficulty
Intermediate
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 bucket sits still.

More loaders

See all
Receipt
Snail
Toaster
Wiper