Nesting Dolls

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

const DOLLS = 5;
const H0 = 96;
const W0 = 58;
const GAP = 4;
const FLOOR = 124;
const size = (i: number) => ({ w: W0 * 0.8 ** i, h: H0 * 0.8 ** i });
// Lined up largest to smallest; a doll still inside waits behind the last one out.
const lineX = (i: number) => 8 + Array.from({ length: i }, (_, j) => size(j).w + GAP).reduce((a, b) => a + b, 0);
function doll(i: number, n: number) {
  const { w, h } = size(i);
  const out = i < n;
  const host = n - 1;
  const x = out ? lineX(i) : lineX(host) + (size(host).w - w) / 2;
  return { left: x, top: FLOOR - h, width: w, height: h, zIndex: out ? 10 + i : 5 - i } as CSSProperties;
}

const css = `
  .slider-nesting-dolls { --face: light-dark(#fbfcfd, #e5e6e9); --ink: light-dark(#373a40, #121417); display: grid; gap: 12px; width: 230px; }
  .slider-nesting-dolls__shelf { position: relative; height: 132px; }
  .slider-nesting-dolls__shelf::after { content: ''; position: absolute; left: 0; right: 0; top: ${FLOOR}px; height: 3px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
  /* Each doll slides along the shelf; one coming out hops as it goes. */
  .slider-nesting-dolls__doll { position: absolute; transition: left 0.5s cubic-bezier(0.3, 1.1, 0.5, 1); }
  .slider-nesting-dolls__doll[data-out] .slider-nesting-dolls__hop { animation: slider-nesting-dolls-hop 0.5s ease-out; }
  .slider-nesting-dolls__hop { position: absolute; inset: 0; transform-origin: 50% 100%; }
  .slider-nesting-dolls__base { position: absolute; left: 0; right: 0; bottom: 0; height: 58%; border-radius: 48% 48% 42% 42% / 34% 34% 66% 66%; background: radial-gradient(ellipse 34% 42% at 50% 46%, color-mix(in oklab, var(--face) 85%, var(--body)) 0 96%, transparent 100%), linear-gradient(90deg, color-mix(in oklab, var(--body) 80%, light-dark(#fbfcfd, #121417)), var(--body) 35%, color-mix(in oklab, var(--body) 75%, light-dark(#373a40, #f1f2f3)) 55%, color-mix(in oklab, var(--body) 82%, light-dark(#fbfcfd, #121417))); }
  /* A flower on the apron. */
  .slider-nesting-dolls__base::after { content: ''; position: absolute; left: 50%; top: 40%; width: 22%; aspect-ratio: 1; translate: -50% -50%; border-radius: 50%; background: radial-gradient(circle, var(--body) 0 22%, transparent 24%), radial-gradient(circle at 50% 12%, light-dark(#6d7179, #7d8087) 0 18%, transparent 20%), radial-gradient(circle at 88% 50%, light-dark(#6d7179, #7d8087) 0 18%, transparent 20%), radial-gradient(circle at 50% 88%, light-dark(#6d7179, #7d8087) 0 18%, transparent 20%), radial-gradient(circle at 12% 50%, light-dark(#6d7179, #7d8087) 0 18%, transparent 20%); }
  .slider-nesting-dolls__top { position: absolute; left: 8%; right: 8%; top: 0; height: 47%; border-radius: 50% 50% 44% 44% / 60% 60% 40% 40%; background: linear-gradient(90deg, color-mix(in oklab, var(--body) 80%, light-dark(#fbfcfd, #121417)), var(--body) 35%, color-mix(in oklab, var(--body) 75%, light-dark(#373a40, #f1f2f3)) 55%, color-mix(in oklab, var(--body) 82%, light-dark(#fbfcfd, #121417))); box-shadow: 0 1.5px 0 color-mix(in oklab, light-dark(#fbfcfd, #121417) 35%, transparent); transform-origin: 30% 100%; }
  .slider-nesting-dolls__doll[data-gave] .slider-nesting-dolls__top { animation: slider-nesting-dolls-lift 0.55s ease-in-out; }
  .slider-nesting-dolls__face { position: absolute; left: 26%; right: 26%; top: 24%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 34% 46%, var(--ink) 0 5%, transparent 7%), radial-gradient(circle at 66% 46%, var(--ink) 0 5%, transparent 7%), radial-gradient(circle at 24% 66%, color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, transparent) 0 10%, transparent 12%), radial-gradient(circle at 76% 66%, color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, transparent) 0 10%, transparent 12%), radial-gradient(ellipse 12% 5% at 50% 74%, var(--ink) 0 90%, transparent 100%), var(--face); box-shadow: 0 -2px 0 2px color-mix(in oklab, var(--body) 70%, light-dark(#fbfcfd, #121417)); }
  .slider-nesting-dolls__read { display: flex; align-items: baseline; gap: 6px; }
  .slider-nesting-dolls__count { color: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
  .slider-nesting-dolls__unit { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
  .slider-nesting-dolls__track { position: relative; height: 22px; }
  .slider-nesting-dolls__rail { position: absolute; left: 11px; right: 11px; top: 9px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
  .slider-nesting-dolls__stop { position: absolute; top: 7px; width: 8px; height: 8px; translate: -50% 0; border-radius: 50%; background: light-dark(#cbced3, #393c41); }
  .slider-nesting-dolls__thumb { position: absolute; top: 0; left: calc(var(--slider-nesting-dolls-v) * (100% - 22px)); width: 22px; height: 22px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); transition: left 0.25s cubic-bezier(0.3, 1.3, 0.5, 1); }
  .slider-nesting-dolls:has(.slider-nesting-dolls__input:focus-visible) .slider-nesting-dolls__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
  .slider-nesting-dolls__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
  .slider-nesting-dolls__input::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; }
  .slider-nesting-dolls__input::-moz-range-thumb { width: 22px; height: 22px; border: 0; }
  @keyframes slider-nesting-dolls-hop { 0% { transform: translateY(0) scale(1, 1); } 30% { transform: translateY(-16px) scale(0.94, 1.06); } 62% { transform: translateY(0) scale(1.08, 0.92); } 80% { transform: translateY(-3px) scale(0.98, 1.02); } 100% { transform: none; } }
  @keyframes slider-nesting-dolls-lift { 0% { transform: none; } 35% { transform: translate(-3px, -14px) rotate(-10deg); } 70% { transform: translate(0, -6px) rotate(-3deg); } 100% { transform: none; } }
  @media (prefers-reduced-motion: reduce) {
    .slider-nesting-dolls * { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const [n, setN] = useState(3);
  return (
    <>
      <style href="kit-sliders-nesting-dolls" precedence="kit">
        {css}
      </style>
      <div className="slider-nesting-dolls" style={{ '--slider-nesting-dolls-v': (n - 1) / (DOLLS - 1) } as CSSProperties}>
        <div className="slider-nesting-dolls__shelf" aria-hidden="true">
          {Array.from({ length: DOLLS }, (_, i) => (
            <span
              key={i}
              className="slider-nesting-dolls__doll"
              data-out={i > 0 && i < n ? '' : undefined}
              data-gave={i + 1 < n ? '' : undefined}
              style={{ ...doll(i, n), '--body': i % 2 ? 'light-dark(#6d7179, #a7abb2)' : 'light-dark(#50535b, #7d8087)' } as CSSProperties}
            >
              <span className="slider-nesting-dolls__hop">
                <span className="slider-nesting-dolls__base" />
                <span className="slider-nesting-dolls__top">
                  <span className="slider-nesting-dolls__face" />
                </span>
              </span>
            </span>
          ))}
        </div>
        <div className="slider-nesting-dolls__read" aria-hidden="true">
          <span className="slider-nesting-dolls__count">{n}</span>
          <span className="slider-nesting-dolls__unit">{n === 1 ? 'doll' : 'dolls'}</span>
        </div>
        <div className="slider-nesting-dolls__track">
          <span className="slider-nesting-dolls__rail" aria-hidden="true" />
          {Array.from({ length: DOLLS }, (_, i) => (
            <span key={i} className="slider-nesting-dolls__stop" style={{ left: `calc(11px + ${i / (DOLLS - 1)} * (100% - 22px))` }} aria-hidden="true" />
          ))}
          <span className="slider-nesting-dolls__thumb" aria-hidden="true" />
          <input className="slider-nesting-dolls__input" type="range" min={1} max={DOLLS} value={n} onChange={(e) => setN(+e.target.value)} aria-label="Dolls" aria-valuetext={`${n} ${n === 1 ? 'doll' : 'dolls'}`} />
        </div>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/slider-nesting-dolls
Source on GitHub

About this component

Choose how many dolls: each new one hops out of the last as its top lifts off, and they line up by size.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, spring, keyframe
Techniques
css transition, keyframes, radial gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native range input named "Dolls" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the dolls move without hops.

More sliders

See all
Phase
99.1MHzClassic
Radio
Scissor Lift
Swing Ride