Louvre
import { useEffect, useRef, useState } from 'react';
const W = 240;
const SLATS = 14;
const SLAT = 13;
const STEP = (W - SLAT) / (SLATS - 1);
const css = `
.slider-louvre { position: relative; width: ${W}px; }
.slider-louvre__head { display: flex; justify-content: space-between; margin-bottom: 10px; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-louvre__head b { font-weight: 500; font-variant-numeric: tabular-nums; color: light-dark(#42454c, #e5e6e9); }
.slider-louvre__slats { position: relative; height: 38px; }
.slider-louvre__slat {
position: absolute;
top: 0;
width: ${SLAT}px;
height: 38px;
border-radius: 3px;
background-color: color-mix(in oklab, light-dark(#cbced3, #393c41) calc(var(--slider-louvre-f) * 100%), light-dark(#42454c, #e5e6e9));
transform: perspective(160px) rotateY(calc(var(--slider-louvre-f) * 80deg));
transition: transform 0.5s cubic-bezier(0.3, 1.45, 0.5, 1), background-color 0.35s ease-out;
transition-delay: var(--slider-louvre-d);
}
.slider-louvre__rail { position: relative; height: 2px; margin-top: 14px; border-radius: 1px; background: light-dark(#ebedf0, #232529); }
.slider-louvre__fill { position: absolute; left: 0; top: 0; height: 2px; border-radius: 1px; width: var(--slider-louvre-t); background: light-dark(#50535b, #c6c8cd); }
.slider-louvre__dot {
position: absolute;
top: -4px;
left: calc(var(--slider-louvre-t) - 5px);
width: 10px;
height: 10px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 3px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5));
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-louvre:has(.slider-louvre__input:active) .slider-louvre__dot { transform: scale(1.3); }
.slider-louvre:has(.slider-louvre__input:focus-visible) .slider-louvre__dot { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-louvre__input { position: absolute; left: 0; right: 0; top: 24px; width: 100%; height: 66px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-louvre__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 66px; }
.slider-louvre__input::-moz-range-thumb { width: 12px; height: 66px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-louvre__slat, .slider-louvre__dot { transition: none; }
}
`;
const smooth = (x: number) => {
const c = Math.min(1, Math.max(0, x));
return c * c * (3 - 2 * c);
};
export function LouvreSlider() {
const [v, setV] = useState(45);
const t = 6 + (v / 100) * (W - 12);
// Slats between the old and new position turn in order, so a click far along still reads as a sweep.
const prev = useRef(t);
useEffect(() => {
prev.current = t;
}, [t]);
const span = Math.abs(t - prev.current);
const sweep = Math.min(span * 1.6, 320);
return (
<>
<style>
{css}
</style>
<div className="slider-louvre" style={{ '--slider-louvre-t': `${t}px` } as React.CSSProperties}>
<div className="slider-louvre__head" aria-hidden="true">
<span>Light</span>
<b>{v}%</b>
</div>
<div className="slider-louvre__slats" aria-hidden="true">
{Array.from({ length: SLATS }, (_, i) => {
const x = SLAT / 2 + i * STEP;
const crossed = span > 0 && (x - prev.current) * (x - t) <= 0;
return (
<span
key={i}
className="slider-louvre__slat"
style={
{
left: x - SLAT / 2,
'--slider-louvre-f': smooth((x - t + 16) / 32).toFixed(3),
'--slider-louvre-d': `${crossed ? Math.round((Math.abs(x - prev.current) / span) * sweep) : 0}ms`,
} as React.CSSProperties
}
/>
);
})}
</div>
<div className="slider-louvre__rail" aria-hidden="true">
<span className="slider-louvre__fill" />
<span className="slider-louvre__dot" />
</div>
<input
className="slider-louvre__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Light"
aria-valuetext={`${v}%`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderLouvre(el) on the mounted element instead.
<div class="slider-louvre" style="--slider-louvre-t:108.6px">
<div class="slider-louvre__head" aria-hidden="true">
<span>Light</span>
<b>45%</b>
</div>
<div class="slider-louvre__slats" aria-hidden="true">
<span class="slider-louvre__slat" style="left:0;--slider-louvre-f:0.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:17.462px;--slider-louvre-f:0.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:34.923px;--slider-louvre-f:0.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:52.385px;--slider-louvre-f:0.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:69.846px;--slider-louvre-f:0.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:87.308px;--slider-louvre-f:0.004;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:104.769px;--slider-louvre-f:0.624;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:122.231px;--slider-louvre-f:1.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:139.692px;--slider-louvre-f:1.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:157.154px;--slider-louvre-f:1.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:174.615px;--slider-louvre-f:1.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:192.077px;--slider-louvre-f:1.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:209.538px;--slider-louvre-f:1.000;--slider-louvre-d:0ms"></span>
<span class="slider-louvre__slat" style="left:227px;--slider-louvre-f:1.000;--slider-louvre-d:0ms"></span>
</div>
<div class="slider-louvre__rail" aria-hidden="true">
<span class="slider-louvre__fill"></span>
<span class="slider-louvre__dot"></span>
</div>
<input class="slider-louvre__input" type="range" min="0" max="100" aria-label="Light" aria-valuetext="45%" value="45"/>
</div>.slider-louvre { position: relative; width: 240px; }
.slider-louvre__head { display: flex; justify-content: space-between; margin-bottom: 10px; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-louvre__head b { font-weight: 500; font-variant-numeric: tabular-nums; color: light-dark(#42454c, #e5e6e9); }
.slider-louvre__slats { position: relative; height: 38px; }
.slider-louvre__slat {
position: absolute;
top: 0;
width: 13px;
height: 38px;
border-radius: 3px;
background-color: color-mix(in oklab, light-dark(#cbced3, #393c41) calc(var(--slider-louvre-f) * 100%), light-dark(#42454c, #e5e6e9));
transform: perspective(160px) rotateY(calc(var(--slider-louvre-f) * 80deg));
transition: transform 0.5s cubic-bezier(0.3, 1.45, 0.5, 1), background-color 0.35s ease-out;
transition-delay: var(--slider-louvre-d);
}
.slider-louvre__rail { position: relative; height: 2px; margin-top: 14px; border-radius: 1px; background: light-dark(#ebedf0, #232529); }
.slider-louvre__fill { position: absolute; left: 0; top: 0; height: 2px; border-radius: 1px; width: var(--slider-louvre-t); background: light-dark(#50535b, #c6c8cd); }
.slider-louvre__dot {
position: absolute;
top: -4px;
left: calc(var(--slider-louvre-t) - 5px);
width: 10px;
height: 10px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 3px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5));
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-louvre:has(.slider-louvre__input:active) .slider-louvre__dot { transform: scale(1.3); }
.slider-louvre:has(.slider-louvre__input:focus-visible) .slider-louvre__dot { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-louvre__input { position: absolute; left: 0; right: 0; top: 24px; width: 100%; height: 66px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-louvre__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 66px; }
.slider-louvre__input::-moz-range-thumb { width: 12px; height: 66px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-louvre__slat, .slider-louvre__dot { transition: none; }
}function initSliderLouvre(root) {
var W = 240, SLAT = 13;
var input = root.querySelector('.slider-louvre__input');
var value = root.querySelector('.slider-louvre__head b');
var slats = root.querySelectorAll('.slider-louvre__slat');
var step = (W - SLAT) / (slats.length - 1);
var prev = 6 + (input.value / 100) * (W - 12);
function smooth(x) {
var c = Math.min(1, Math.max(0, x));
return c * c * (3 - 2 * c);
}
function update() {
var v = +input.value;
var t = 6 + (v / 100) * (W - 12);
// Slats between the old and new position turn in order, so a click far along still reads as a sweep.
var span = Math.abs(t - prev);
var sweep = Math.min(span * 1.6, 320);
slats.forEach(function (slat, i) {
var x = SLAT / 2 + i * step;
var crossed = span > 0 && (x - prev) * (x - t) <= 0;
slat.style.setProperty('--slider-louvre-f', smooth((x - t + 16) / 32).toFixed(3));
slat.style.setProperty('--slider-louvre-d', (crossed ? Math.round((Math.abs(x - prev) / span) * sweep) : 0) + 'ms');
});
prev = t;
root.style.setProperty('--slider-louvre-t', t + 'px');
value.textContent = v + '%';
input.setAttribute('aria-valuetext', v + '%');
}
input.addEventListener('input', update);
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-louvre').forEach(initSliderLouvre);Goal: Create a "Louvre" slider.
Visual behavior: A light slider made of slats that turn face-on as the thumb passes over them and edge-on as it leaves, so every drag sends a wave along the row.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Each slat gets a turn value from 0 to 1, a smoothstep of its distance to the thumb; CSS turns it with perspective() rotateY() and mixes its colour from the same value.
- When the value jumps, slats between the old and new positions get transition delays in proportion to their distance from the old one, so they turn in order rather than all at once.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the small thumb under the slats. Under prefers-reduced-motion the slats turn without easing.
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 "Louvre" slider.
Visual behavior: A light slider made of slats that turn face-on as the thumb passes over them and edge-on as it leaves, so every drag sends a wave along the row.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Each slat gets a turn value from 0 to 1, a smoothstep of its distance to the thumb; CSS turns it with perspective() rotateY() and mixes its colour from the same value.
- When the value jumps, slats between the old and new positions get transition delays in proportion to their distance from the old one, so they turn in order rather than all at once.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the small thumb under the slats. Under prefers-reduced-motion the slats turn without easing.
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 light slider made of slats that turn face-on as the thumb passes over them and edge-on as it leaves, so every drag sends a wave along the row.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous, 3d, delay stagger
- Techniques
- 3d transform, perspective, css variables, transition delay stagger
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the small thumb under the slats. Under prefers-reduced-motion the slats turn without easing.