Shuffle

No dependencies
Code format
React + Tailwind
const T = 3.8;
const N = 10;
const HALF = N / 2;
const pct = (f: number) => `${(f * 100).toFixed(2)}%`;
// Riffle order: the halves drop in turn from the bottom, left first.
const order = (i: number) => (i < HALF ? 2 * i : 2 * (i - HALF) + 1);
const landAt = (k: number) => 0.21 + k * 0.034;
const tf = (x: number, y: number, r: number, f = 0) => `transform: translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${r.toFixed(1)}deg) rotateY(${f}deg)`;

function cardFrames(i: number) {
  const k = order(i);
  const side = i < HALF ? -1 : 1;
  const j = i < HALF ? i : i - HALF;
  const pile = tf(side * 64 - side * j * 0.4, 6 - j * 0.9, side * 13);
  const jx = (((k * 7) % 5) - 2) * 1.3;
  const jr = (((k * 5) % 7) - 3) * 1.4;
  const landed = tf(jx, -k * 0.7, jr);
  const squared = tf(0, -k * 0.7, 0);
  const top = i === N - 1;
  return [
    `0%, ${pct(0.05)} { ${tf(0, -i * 0.7, 0)}; z-index: ${i}; animation-timing-function: cubic-bezier(0.5, 0, 0.3, 1); }`,
    `${pct(0.17)}, ${pct(landAt(k) - 0.001)} { ${pile}; z-index: ${i}; animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1); }`,
    `${pct(landAt(k))} { ${pile}; z-index: ${20 + k}; animation-timing-function: cubic-bezier(0.3, 0, 0.4, 1.3); }`,
    `${pct(landAt(k) + 0.045)}, ${pct(0.58)} { ${landed}; z-index: ${20 + k}; animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }`,
    ...(top
      ? [
          `${pct(0.66)}, ${pct(0.7)} { ${squared}; z-index: ${20 + k}; animation-timing-function: cubic-bezier(0.4, 0, 0.3, 1); }`,
          `${pct(0.77)}, ${pct(0.87)} { ${tf(0, -k * 0.7 - 10, 0, 180)}; z-index: ${20 + k}; animation-timing-function: cubic-bezier(0.4, 0, 0.3, 1); }`,
          `${pct(0.94)} { ${tf(0, -k * 0.7, 0, 360)}; z-index: ${20 + k}; }`,
          `100% { ${tf(0, -i * 0.7, 0, 360)}; z-index: ${i}; }`,
        ]
      : [`${pct(0.66)}, ${pct(0.95)} { ${squared}; z-index: ${20 + k}; }`, `100% { ${tf(0, -i * 0.7, 0)}; z-index: ${i}; }`]),
  ].join(' ');
}

const css = `
  .loader-shuffle { --stock: light-dark(#fbfcfd, #e5e6e9); --ink: light-dark(#373a40, #121417); --back: light-dark(#50535b, #393c41); position: relative; width: 230px; height: 176px; perspective: 700px; }
  .loader-shuffle__card { position: absolute; left: 83px; top: 44px; width: 64px; height: 90px; transform-style: preserve-3d; animation: ${T}s infinite; }
  .loader-shuffle__back, .loader-shuffle__face { position: absolute; inset: 0; border-radius: 5px; backface-visibility: hidden; box-shadow: 0 0 0 0.5px color-mix(in oklab, var(--ink) 30%, transparent), 0 1px 2px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); }
  /* The back: white border round a fine lattice, framed by a hairline. */
  .loader-shuffle__back { background: repeating-linear-gradient(45deg, var(--back) 0 1px, transparent 1px 4.5px) 6px 6px / 52px 78px no-repeat, repeating-linear-gradient(-45deg, var(--back) 0 1px, transparent 1px 4.5px) 6px 6px / 52px 78px no-repeat, var(--stock); outline: 1px solid var(--back); outline-offset: -7px; }
  .loader-shuffle__face { transform: rotateY(180deg); background: var(--stock); color: var(--ink); font-family: Georgia, 'Times New Roman', serif; }
  .loader-shuffle__index { position: absolute; left: 5px; top: 4px; display: grid; justify-items: center; font-size: 13px; font-weight: 700; line-height: 1; }
  .loader-shuffle__index small { font-size: 10px; }
  .loader-shuffle__index--low { left: auto; top: auto; right: 5px; bottom: 4px; rotate: 180deg; }
  .loader-shuffle__pip { position: absolute; inset: 0; display: grid; place-items: center; font-size: 36px; line-height: 1; }
  ${Array.from({ length: N }, (_, i) => `.loader-shuffle__card:nth-child(${i + 1}) { animation-name: loader-shuffle-card-${i}; } @keyframes loader-shuffle-card-${i} { ${cardFrames(i)} }`).join('\n  ')}
  @media (prefers-reduced-motion: reduce) {
    .loader-shuffle__card { animation: none !important; }
    ${Array.from({ length: N }, (_, i) => `.loader-shuffle__card:nth-child(${i + 1}) { transform: translate(${(i < HALF ? -1 : 1) * 30}px, ${-(i % HALF) * 0.7}px) rotate(${(i < HALF ? -1 : 1) * 6}deg); }`).join(' ')}
  }
`;

export function Preview() {
  return (
    <>
      <style href="kit-loaders-shuffle" precedence="kit">
        {css}
      </style>
      <div className="loader-shuffle" role="status" aria-label="Loading">
        {Array.from({ length: N }, (_, i) => (
          <span key={i} className="loader-shuffle__card" aria-hidden="true">
            <span className="loader-shuffle__back" />
            {i === N - 1 && (
              <span className="loader-shuffle__face">
                <span className="loader-shuffle__index">
                  A<small>♠</small>
                </span>
                <span className="loader-shuffle__pip">♠</span>
                <span className="loader-shuffle__index loader-shuffle__index--low">
                  A<small>♠</small>
                </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-shuffle
Source on GitHub

About this component

A deck is cut, riffled together card by card, squared up, and the top card flips over to show an ace.

Dependencies
None
Difficulty
Advanced
Triggers
continuous, loop, 3d, delay stagger
Techniques
keyframes, preserve 3d, 3d transform, perspective
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 two halves sit fanned apart, ready to riffle.

More loaders

See all
Sine
Snow Globe
Spirograph
Stairs