Columns
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderColumns(el) on the mounted element instead.
<div class="slider-columns" style="--slider-columns-n:3;--slider-columns-v:0.4">
<div class="slider-columns__window" aria-hidden="true">
<span class="slider-columns__bar"></span>
<span class="slider-columns__page">
<span class="slider-columns__block slider-columns__block--image" style="--l:0.0px;--t:0.0px;--w:66.0px;--h:47.0px;--d:0ms"></span>
<span class="slider-columns__block slider-columns__block--text" style="--l:72.0px;--t:0.0px;--w:66.0px;--h:47.0px;--d:32ms"></span>
<span class="slider-columns__block slider-columns__block--text" style="--l:144.0px;--t:0.0px;--w:66.0px;--h:47.0px;--d:64ms"></span>
<span class="slider-columns__block slider-columns__block--image" style="--l:0.0px;--t:53.0px;--w:66.0px;--h:47.0px;--d:96ms"></span>
<span class="slider-columns__block slider-columns__block--text" style="--l:72.0px;--t:53.0px;--w:66.0px;--h:47.0px;--d:128ms"></span>
<span class="slider-columns__block slider-columns__block--text" style="--l:144.0px;--t:53.0px;--w:66.0px;--h:47.0px;--d:160ms"></span>
</span>
</div>
<div class="slider-columns__track">
<span class="slider-columns__rail" aria-hidden="true"></span>
<span class="slider-columns__stop" style="left:calc(13px + 0 * (100% - 26px))" aria-hidden="true">
<b>1</b>
</span>
<span class="slider-columns__stop" style="left:calc(13px + 0.2 * (100% - 26px))" aria-hidden="true">
<b>2</b>
</span>
<span class="slider-columns__stop" data-on="" style="left:calc(13px + 0.4 * (100% - 26px))" aria-hidden="true">
<b>3</b>
</span>
<span class="slider-columns__stop" style="left:calc(13px + 0.6 * (100% - 26px))" aria-hidden="true">
<b>4</b>
</span>
<span class="slider-columns__stop" style="left:calc(13px + 0.8 * (100% - 26px))" aria-hidden="true">
<b>5</b>
</span>
<span class="slider-columns__stop" style="left:calc(13px + 1 * (100% - 26px))" aria-hidden="true">
<b>6</b>
</span>
<span class="slider-columns__thumb" aria-hidden="true"></span>
<input class="slider-columns__input" type="range" min="1" max="6" aria-label="Columns" aria-valuetext="3 columns" value="3"/>
</div>
</div>.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: 210px; height: 100px; }
/* 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; }
}function initSliderColumns(root) {
var input = root.querySelector('.slider-columns__input');
var blocks = root.querySelectorAll('.slider-columns__block');
var stops = root.querySelectorAll('.slider-columns__stop');
var width = 210;
var height = 100;
var gap = 6;
function update() {
var n = +input.value;
// Row by row, the blocks sharing the page's height; each springs to its new box.
var rows = Math.ceil(blocks.length / n);
var w = (width - (n - 1) * gap) / n;
var h = (height - (rows - 1) * gap) / rows;
blocks.forEach(function (block, i) {
block.style.setProperty('--l', ((i % n) * (w + gap)).toFixed(1) + 'px');
block.style.setProperty('--t', (Math.floor(i / n) * (h + gap)).toFixed(1) + 'px');
block.style.setProperty('--w', w.toFixed(1) + 'px');
block.style.setProperty('--h', h.toFixed(1) + 'px');
});
stops.forEach(function (stop, i) { stop.toggleAttribute('data-on', i + 1 === n); });
root.style.setProperty('--slider-columns-n', n);
root.style.setProperty('--slider-columns-v', (n - 1) / 5);
input.setAttribute('aria-valuetext', n + (n === 1 ? ' column' : ' columns'));
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-columns').forEach(initSliderColumns);Goal: Create a "Columns" slider.
Visual behavior: A page layout that reflows from one to six columns, the blocks springing to their new places one after another.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- Six blocks are placed row by row for the column count, sharing the page height. Each block gets its box as four variables.
- Every block transitions to its new box on a springy curve, each a beat after the one before, so the reflow ripples across the page.
- The handle draws the column count itself: a gradient tile sized to 1/n of its width repeats into n bars.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Columns" slider.
Visual behavior: A page layout that reflows from one to six columns, the blocks springing to their new places one after another.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- Six blocks are placed row by row for the column count, sharing the page height. Each block gets its box as four variables.
- Every block transitions to its new box on a springy curve, each a beat after the one before, so the reflow ripples across the page.
- The handle draws the column count itself: a gradient tile sized to 1/n of its width repeats into n bars.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.