Foil
import { useEffect, useRef, useState } from 'react';
const W = 240;
const T = 22;
const MID = 22;
const css = `
.slider-foil {
position: relative;
width: ${W}px;
height: 62px;
--slider-foil-hl: light-dark(#fbfcfd, #f1f2f3);
}
.slider-foil__track, .slider-foil__fill { position: absolute; left: 0; top: ${MID - 6}px; height: 12px; border-radius: 6px; }
.slider-foil__track { width: ${W}px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 1px 2px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.45)); }
.slider-foil__fill {
width: calc(var(--slider-foil-x) + ${T / 2}px);
overflow: hidden;
/* Brushed: fine lines along the strip over a soft fall-off from top to bottom. */
background:
repeating-linear-gradient(180deg, color-mix(in oklab, var(--slider-foil-hl) 14%, transparent) 0 1px, transparent 1px 2px),
linear-gradient(180deg, light-dark(#5f626a, #a7abb2), light-dark(#50535b, #c6c8cd) 45%, light-dark(#6d7179, #7d8087));
box-shadow: inset 0 0 0 0.5px color-mix(in oklab, var(--slider-foil-hl) 25%, transparent);
}
.slider-foil__sheen {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--slider-foil-sheen) - 36px);
width: 72px;
background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--slider-foil-hl) 70%, transparent) 46%, color-mix(in oklab, var(--slider-foil-hl) 85%, transparent) 50%, color-mix(in oklab, var(--slider-foil-hl) 70%, transparent) 54%, transparent);
opacity: 0.75;
transition: opacity 0.25s;
}
.slider-foil:has(.slider-foil__input:active) .slider-foil__sheen { opacity: 1; }
.slider-foil__thumb {
position: absolute;
top: ${MID - T / 2}px;
left: calc(var(--slider-foil-x) - ${T / 2}px);
width: ${T}px;
height: ${T}px;
border-radius: 50%;
background:
radial-gradient(circle at calc(38% + var(--slider-foil-tilt) * 1%) 30%, var(--slider-foil-hl) 0 12%, transparent 34%),
radial-gradient(circle at 50% 120%, color-mix(in oklab, var(--slider-foil-hl) 35%, transparent), transparent 55%),
linear-gradient(180deg, light-dark(#50535b, #c6c8cd), light-dark(#6d7179, #7d8087));
box-shadow: 0 1px 1px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5)), 0 4px 10px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)), inset 0 0 0 0.5px color-mix(in oklab, var(--slider-foil-hl) 40%, transparent);
}
.slider-foil:has(.slider-foil__input:focus-visible) .slider-foil__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-foil__value { position: absolute; left: 0; right: 0; top: 44px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-foil__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-foil__input { position: absolute; left: 0; top: 0; width: 100%; height: 44px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-foil__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${T}px; height: 44px; }
.slider-foil__input::-moz-range-thumb { width: ${T}px; height: 44px; border: 0; }
`;
/**
* The sheen lags the thumb on a loose spring, and the lag is how far the foil has "tilted":
* drag and the light slides back across the metal, then catches up as you stop.
*/
function useSheen(target: number) {
const [s, setS] = useState({ x: target, v: 0 });
const st = useRef({ x: target, v: 0, raf: 0 });
useEffect(() => {
const q = st.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.x = target;
setS({ x: target, v: 0 });
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-70 * (q.x - target) - 13 * q.v) * dt;
q.x += q.v * dt;
if (Math.abs(q.x - target) < 0.05 && Math.abs(q.v) < 0.5) {
q.x = target;
q.v = 0;
setS({ x: target, v: 0 });
return;
}
setS({ x: q.x, v: q.v });
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [target]);
return s;
}
export function FoilSlider() {
const [v, setV] = useState(62);
const x = T / 2 + (v / 100) * (W - T);
const sheen = useSheen(x);
// At rest the light sits two thirds of the way along the fill.
const at = sheen.x * 0.66 + 6 - sheen.v * 0.05;
const tilt = Math.max(-18, Math.min(18, -sheen.v * 0.06));
return (
<>
<style>
{css}
</style>
<div className="slider-foil" style={{ '--slider-foil-x': `${x}px`, '--slider-foil-sheen': `${at}px`, '--slider-foil-tilt': tilt.toFixed(2) } as React.CSSProperties}>
<span className="slider-foil__track" aria-hidden="true" />
<span className="slider-foil__fill" aria-hidden="true">
<span className="slider-foil__sheen" />
</span>
<span className="slider-foil__thumb" aria-hidden="true" />
<p className="slider-foil__value" aria-hidden="true">
<span>Brightness</span>
<b>{v}%</b>
</p>
<input className="slider-foil__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Brightness" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderFoil(el) on the mounted element instead.
<div class="slider-foil" style="--slider-foil-x:146.16px;--slider-foil-sheen:102.466px;--slider-foil-tilt:0.00">
<span class="slider-foil__track" aria-hidden="true"></span>
<span class="slider-foil__fill" aria-hidden="true">
<span class="slider-foil__sheen"></span>
</span>
<span class="slider-foil__thumb" aria-hidden="true"></span>
<p class="slider-foil__value" aria-hidden="true">
<span>Brightness</span>
<b>62%</b>
</p>
<input class="slider-foil__input" type="range" min="0" max="100" aria-label="Brightness" aria-valuetext="62%" value="62"/>
</div>.slider-foil {
position: relative;
width: 240px;
height: 62px;
--slider-foil-hl: light-dark(#fbfcfd, #f1f2f3);
}
.slider-foil__track, .slider-foil__fill { position: absolute; left: 0; top: 16px; height: 12px; border-radius: 6px; }
.slider-foil__track { width: 240px; background: light-dark(#ebedf0, #232529); box-shadow: inset 0 1px 2px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.45)); }
.slider-foil__fill {
width: calc(var(--slider-foil-x) + 11px);
overflow: hidden;
/* Brushed: fine lines along the strip over a soft fall-off from top to bottom. */
background:
repeating-linear-gradient(180deg, color-mix(in oklab, var(--slider-foil-hl) 14%, transparent) 0 1px, transparent 1px 2px),
linear-gradient(180deg, light-dark(#5f626a, #a7abb2), light-dark(#50535b, #c6c8cd) 45%, light-dark(#6d7179, #7d8087));
box-shadow: inset 0 0 0 0.5px color-mix(in oklab, var(--slider-foil-hl) 25%, transparent);
}
.slider-foil__sheen {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--slider-foil-sheen) - 36px);
width: 72px;
background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--slider-foil-hl) 70%, transparent) 46%, color-mix(in oklab, var(--slider-foil-hl) 85%, transparent) 50%, color-mix(in oklab, var(--slider-foil-hl) 70%, transparent) 54%, transparent);
opacity: 0.75;
transition: opacity 0.25s;
}
.slider-foil:has(.slider-foil__input:active) .slider-foil__sheen { opacity: 1; }
.slider-foil__thumb {
position: absolute;
top: 11px;
left: calc(var(--slider-foil-x) - 11px);
width: 22px;
height: 22px;
border-radius: 50%;
background:
radial-gradient(circle at calc(38% + var(--slider-foil-tilt) * 1%) 30%, var(--slider-foil-hl) 0 12%, transparent 34%),
radial-gradient(circle at 50% 120%, color-mix(in oklab, var(--slider-foil-hl) 35%, transparent), transparent 55%),
linear-gradient(180deg, light-dark(#50535b, #c6c8cd), light-dark(#6d7179, #7d8087));
box-shadow: 0 1px 1px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5)), 0 4px 10px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)), inset 0 0 0 0.5px color-mix(in oklab, var(--slider-foil-hl) 40%, transparent);
}
.slider-foil:has(.slider-foil__input:focus-visible) .slider-foil__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-foil__value { position: absolute; left: 0; right: 0; top: 44px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-foil__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-foil__input { position: absolute; left: 0; top: 0; width: 100%; height: 44px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-foil__input::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 44px; }
.slider-foil__input::-moz-range-thumb { width: 22px; height: 44px; border: 0; }function initSliderFoil(root) {
var W = 240;
var T = 22;
var input = root.querySelector('.slider-foil__input');
var label = root.querySelector('.slider-foil__value b');
var x = target();
// The sheen lags the thumb on a loose spring, and the lag is how far the foil has "tilted".
var s = x;
var v = 0;
var raf = 0;
function target() { return T / 2 + (+input.value / 100) * (W - T); }
function draw() {
// At rest the light sits two thirds of the way along the fill.
root.style.setProperty('--slider-foil-x', x + 'px');
root.style.setProperty('--slider-foil-sheen', s * 0.66 + 6 - v * 0.05 + 'px');
root.style.setProperty('--slider-foil-tilt', Math.max(-18, Math.min(18, -v * 0.06)).toFixed(2));
}
function run() {
cancelAnimationFrame(raf);
x = target();
label.textContent = input.value + '%';
input.setAttribute('aria-valuetext', input.value + '%');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s = x;
v = 0;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-70 * (s - x) - 13 * v) * dt;
s += v * dt;
if (Math.abs(s - x) < 0.05 && Math.abs(v) < 0.5) {
s = x;
v = 0;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', run);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', run);
};
}
document.querySelectorAll('.slider-foil').forEach(initSliderFoil);Goal: Create a "Foil" slider.
Visual behavior: A brightness slider in brushed metal with a polished bead for a thumb: the sheen slides back across the metal as you drag, like foil tilting under a light, and the bead's highlight shifts with it.
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.
- The fill is two backgrounds: fine repeating lines over a soft fall-off from top to bottom. Inside it, a highlight band sits two thirds of the way along at rest, and the bead is radial gradients over a linear one.
- The band follows the thumb on a loose spring, and the lag is the tilt: drag and the light slides back across the metal, then catches up as you stop. The same lag shifts the highlight on the bead. Highlights are light-dark() pairs, so the metal reads in both themes.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the bead. Under prefers-reduced-motion the sheen moves with the thumb without lagging, and the bead's highlight stays put.
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 "Foil" slider.
Visual behavior: A brightness slider in brushed metal with a polished bead for a thumb: the sheen slides back across the metal as you drag, like foil tilting under a light, and the bead's highlight shifts with it.
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.
- The fill is two backgrounds: fine repeating lines over a soft fall-off from top to bottom. Inside it, a highlight band sits two thirds of the way along at rest, and the bead is radial gradients over a linear one.
- The band follows the thumb on a loose spring, and the lag is the tilt: drag and the light slides back across the metal, then catches up as you stop. The same lag shifts the highlight on the bead. Highlights are light-dark() pairs, so the metal reads in both themes.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the bead. Under prefers-reduced-motion the sheen moves with the thumb without lagging, and the bead's highlight stays put.
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 brightness slider in brushed metal with a polished bead for a thumb: the sheen slides back across the metal as you drag, like foil tilting under a light, and the bead's highlight shifts with it.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, gradient, spring
- Techniques
- css variables, gradient, radial gradient, spring
- 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 bead. Under prefers-reduced-motion the sheen moves with the thumb without lagging, and the bead's highlight stays put.