Columns

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

const W = 210; // the page's content box
const H = 100;
const GAP = 6;
const KINDS = ['image', 'text', 'text', 'image', 'text', 'text'];
// Where each block sits in n columns: row by row, the blocks sharing the height.
function place(i: number, n: number) {
  const rows = Math.ceil(KINDS.length / n);
  const w = (W - (n - 1) * GAP) / n;
  const h = (H - (rows - 1) * GAP) / rows;
  return { '--l': `${((i % n) * (w + GAP)).toFixed(1)}px`, '--t': `${(Math.floor(i / n) * (h + GAP)).toFixed(1)}px`, '--w': `${w.toFixed(1)}px`, '--h': `${h.toFixed(1)}px`, '--d': `${i * 32}ms` } as CSSProperties;
}

const css = `
  .slider-columns { display: grid; gap: 14px; width: 230px; }
  /* A browser window holding the layout. */
  .slider-columns__window { position: relative; height: 134px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 22px -14px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); }
  .slider-columns__bar { position: absolute; left: 10px; top: 7px; width: 5px; height: 5px; border-radius: 50%; background: light-dark(#cbced3, #393c41); box-shadow: 8px 0 0 light-dark(#cbced3, #393c41), 16px 0 0 light-dark(#cbced3, #393c41); }
  .slider-columns__page { position: absolute; left: 10px; top: 22px; width: ${W}px; height: ${H}px; }
  /* Every block springs to its new box, a beat after the one before it. */
  .slider-columns__block { position: absolute; left: var(--l); top: var(--t); width: var(--w); height: var(--h); overflow: hidden; border-radius: 5px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: left 0.5s, top 0.5s, width 0.5s, height 0.5s; transition-timing-function: cubic-bezier(0.3, 1.25, 0.45, 1); transition-delay: var(--d); }
  .slider-columns__block--text::before { content: ''; position: absolute; left: 7px; right: 7px; top: 7px; height: min(19px, calc(100% - 12px)); overflow: hidden; background: linear-gradient(light-dark(#6d7179, #7d8087), light-dark(#6d7179, #7d8087)) 0 0 / 82% 3px no-repeat, linear-gradient(light-dark(#cbced3, #393c41), light-dark(#cbced3, #393c41)) 0 8px / 100% 3px no-repeat, linear-gradient(light-dark(#cbced3, #393c41), light-dark(#cbced3, #393c41)) 0 16px / 58% 3px no-repeat; }
  .slider-columns__block--image { background: linear-gradient(light-dark(#dbdde1, #2e3035), light-dark(#ebedf0, #232529)); }
  .slider-columns__block--image::before { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: min(60%, 40px); background: light-dark(#cbced3, #393c41); clip-path: polygon(0 100%, 0 70%, 28% 18%, 46% 52%, 64% 30%, 100% 78%, 100% 100%); }
  .slider-columns__block--image::after { content: ''; position: absolute; right: 9px; top: 7px; width: 7px; height: 7px; border-radius: 50%; background: light-dark(#6d7179, #7d8087); }
  /* The track: six stops, and a handle that draws the column count. */
  .slider-columns__track { position: relative; height: 34px; }
  .slider-columns__rail { position: absolute; left: 13px; right: 13px; top: 8px; height: 2px; border-radius: 1px; background: light-dark(#dbdde1, #2e3035); }
  .slider-columns__stop { position: absolute; top: 5px; width: 8px; height: 8px; translate: -50% 0; border-radius: 50%; background: light-dark(#dbdde1, #2e3035); }
  .slider-columns__stop b { position: absolute; left: 50%; top: 13px; translate: -50% 0; color: light-dark(#6d7179, #7d8087); font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; transition: color 0.2s; }
  .slider-columns__stop[data-on] b { color: light-dark(#373a40, #f1f2f3); }
  .slider-columns__thumb { position: absolute; top: 0; left: calc(var(--slider-columns-v) * (100% - 26px)); width: 26px; height: 18px; box-sizing: border-box; padding: 4px 5px; border-radius: 6px; 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-columns__thumb::before { content: ''; display: block; height: 100%; background: linear-gradient(90deg, light-dark(#f3f4f7, #1a1c1f) calc(100% - 1.5px), transparent 0) 0 0 / calc((100% + 1.5px) / var(--slider-columns-n)) 100% repeat-x; }
  .slider-columns:has(.slider-columns__input:focus-visible) .slider-columns__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .slider-columns__input { position: absolute; left: 0; top: -4px; width: 100%; height: 26px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
  .slider-columns__input::-webkit-slider-thumb { -webkit-appearance: none; width: 26px; height: 26px; }
  .slider-columns__input::-moz-range-thumb { width: 26px; height: 26px; border: 0; }
  @media (prefers-reduced-motion: reduce) {
    .slider-columns * { transition: none !important; }
  }
`;

export function Preview() {
  const [n, setN] = useState(3);
  return (
    <>
      <style href="kit-sliders-columns" precedence="kit">
        {css}
      </style>
      <div className="slider-columns" style={{ '--slider-columns-n': n, '--slider-columns-v': (n - 1) / 5 } as CSSProperties}>
        <div className="slider-columns__window" aria-hidden="true">
          <span className="slider-columns__bar" />
          <span className="slider-columns__page">
            {KINDS.map((kind, i) => (
              <span key={i} className={`slider-columns__block slider-columns__block--${kind}`} style={place(i, n)} />
            ))}
          </span>
        </div>
        <div className="slider-columns__track">
          <span className="slider-columns__rail" aria-hidden="true" />
          {[1, 2, 3, 4, 5, 6].map((s) => (
            <span key={s} className="slider-columns__stop" data-on={s === n ? '' : undefined} style={{ left: `calc(13px + ${(s - 1) / 5} * (100% - 26px))` }} aria-hidden="true">
              <b>{s}</b>
            </span>
          ))}
          <span className="slider-columns__thumb" aria-hidden="true" />
          <input className="slider-columns__input" type="range" min={1} max={6} value={n} onChange={(e) => setN(+e.target.value)} aria-label="Columns" aria-valuetext={`${n} ${n === 1 ? 'column' : 'columns'}`} />
        </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-columns
Source on GitHub

About this component

A page layout that reflows from one to six columns, the blocks springing to their new places one after another.

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

A native range input named "Columns" 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 blocks jump straight to their new places.

More sliders

See all
Crane
Dice
Dive
Egg