Rings
import { useState } from 'react';
const RINGS = Array.from({ length: 16 }, (_, k) => k);
const css = `
.slider-rings { display: grid; justify-items: center; gap: 12px; width: 220px; }
.slider-rings__stump { position: relative; display: grid; place-items: center; width: 116px; height: 116px; }
/* The trunk widens with age; rings appear from the pith outwards, a little uneven like real wood. */
.slider-rings__wood { position: relative; width: calc(44px + var(--slider-rings-v) * 70px); aspect-ratio: 1; border-radius: 48% 52% 50% 50% / 52% 48% 52% 48%; background: radial-gradient(circle, light-dark(#6d7179, #7d8087), light-dark(#cbced3, #393c41)); box-shadow: 0 0 0 4px light-dark(#ebedf0, #232529), 0 0 0 5px light-dark(#dbdde1, #2e3035), 0 10px 18px -8px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); transition: width 0.4s cubic-bezier(0.3, 1.2, 0.5, 1); }
.slider-rings__ring { position: absolute; left: 50%; top: 50%; width: calc(var(--f) * 100%); aspect-ratio: 1; border-radius: var(--shape); border: 1px solid color-mix(in oklab, light-dark(#ebedf0, #232529) 70%, transparent); transform: translate(-50%, -50%) scale(var(--on)); opacity: var(--on); transition: transform 0.4s cubic-bezier(0.3, 1.4, 0.5, 1), opacity 0.3s, width 0.4s; }
.slider-rings__crack { position: absolute; left: 50%; top: 18%; width: 1px; height: 32%; background: color-mix(in oklab, light-dark(#f3f4f7, #1a1c1f) 60%, transparent); transform: rotate(14deg); opacity: var(--slider-rings-v); }
@media (prefers-reduced-motion: reduce) {
.slider-rings__wood, .slider-rings__ring { transition: none; }
}
.slider-rings__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; font-variant-numeric: tabular-nums; }
.slider-rings__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.slider-rings__track { position: relative; width: 100%; height: 24px; }
.slider-rings__rail { position: absolute; left: 0; right: 0; top: 10px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-rings__fill { position: absolute; left: 0; top: 10px; height: 4px; border-radius: 2px; width: calc(10px + var(--slider-rings-v) * (100% - 20px)); background: light-dark(#50535b, #c6c8cd); }
.slider-rings__thumb { position: absolute; top: 2px; left: calc(var(--slider-rings-v) * (100% - 20px)); width: 20px; height: 20px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.slider-rings:has(.slider-rings__input:focus-visible) .slider-rings__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-rings__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-rings__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; }
.slider-rings__input::-moz-range-thumb { width: 20px; height: 20px; border: 0; }
`;
export function RingsSlider() {
const [v, setV] = useState(48);
const t = (v - 1) / (120 - 1);
const shown = Math.max(1, Math.round(t * RINGS.length));
return (
<>
<style>
{css}
</style>
<div className="slider-rings" style={{ '--slider-rings-v': t } as React.CSSProperties}>
<div className="slider-rings__stump" aria-hidden="true">
<span className="slider-rings__wood">
{RINGS.map((k) => (
<span
key={k}
className="slider-rings__ring"
style={{
'--f': ((k + 1) / (shown + 0.6)).toFixed(3),
'--on': k < shown ? 1 : 0,
'--shape': `${48 + (k % 3)}% ${52 - (k % 3)}% ${50 + (k % 2)}% ${50 - (k % 2)}% / ${52 - (k % 2)}% 48% 52% ${48 + (k % 3)}%`,
} as React.CSSProperties}
/>
))}
<span className="slider-rings__crack" />
</span>
</div>
<span className="slider-rings__read" aria-hidden="true">
<b>{v}</b> {v === 1 ? 'year' : 'years'} old
</span>
<div className="slider-rings__track">
<span className="slider-rings__rail" />
<span className="slider-rings__fill" />
<span className="slider-rings__thumb" />
<input
className="slider-rings__input"
type="range"
min={1}
max={120}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Tree age"
aria-valuetext={`${v} ${v === 1 ? 'year' : 'years'}`}
/>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderRings(el) on the mounted element instead.
<div class="slider-rings" style="--slider-rings-v:0.3949579831932773">
<div class="slider-rings__stump" aria-hidden="true">
<span class="slider-rings__wood">
<span class="slider-rings__ring" style="--f:0.152;--on:1;--shape:48% 52% 50% 50% / 52% 48% 52% 48%"></span>
<span class="slider-rings__ring" style="--f:0.303;--on:1;--shape:49% 51% 51% 49% / 51% 48% 52% 49%"></span>
<span class="slider-rings__ring" style="--f:0.455;--on:1;--shape:50% 50% 50% 50% / 52% 48% 52% 50%"></span>
<span class="slider-rings__ring" style="--f:0.606;--on:1;--shape:48% 52% 51% 49% / 51% 48% 52% 48%"></span>
<span class="slider-rings__ring" style="--f:0.758;--on:1;--shape:49% 51% 50% 50% / 52% 48% 52% 49%"></span>
<span class="slider-rings__ring" style="--f:0.909;--on:1;--shape:50% 50% 51% 49% / 51% 48% 52% 50%"></span>
<span class="slider-rings__ring" style="--f:1.061;--on:0;--shape:48% 52% 50% 50% / 52% 48% 52% 48%"></span>
<span class="slider-rings__ring" style="--f:1.212;--on:0;--shape:49% 51% 51% 49% / 51% 48% 52% 49%"></span>
<span class="slider-rings__ring" style="--f:1.364;--on:0;--shape:50% 50% 50% 50% / 52% 48% 52% 50%"></span>
<span class="slider-rings__ring" style="--f:1.515;--on:0;--shape:48% 52% 51% 49% / 51% 48% 52% 48%"></span>
<span class="slider-rings__ring" style="--f:1.667;--on:0;--shape:49% 51% 50% 50% / 52% 48% 52% 49%"></span>
<span class="slider-rings__ring" style="--f:1.818;--on:0;--shape:50% 50% 51% 49% / 51% 48% 52% 50%"></span>
<span class="slider-rings__ring" style="--f:1.970;--on:0;--shape:48% 52% 50% 50% / 52% 48% 52% 48%"></span>
<span class="slider-rings__ring" style="--f:2.121;--on:0;--shape:49% 51% 51% 49% / 51% 48% 52% 49%"></span>
<span class="slider-rings__ring" style="--f:2.273;--on:0;--shape:50% 50% 50% 50% / 52% 48% 52% 50%"></span>
<span class="slider-rings__ring" style="--f:2.424;--on:0;--shape:48% 52% 51% 49% / 51% 48% 52% 48%"></span>
<span class="slider-rings__crack"></span>
</span>
</div>
<span class="slider-rings__read" aria-hidden="true"><b>48</b> years old</span>
<div class="slider-rings__track">
<span class="slider-rings__rail"></span>
<span class="slider-rings__fill"></span>
<span class="slider-rings__thumb"></span>
<input class="slider-rings__input" type="range" min="1" max="120" aria-label="Tree age" aria-valuetext="48 years" value="48"/>
</div>
</div>.slider-rings { display: grid; justify-items: center; gap: 12px; width: 220px; }
.slider-rings__stump { position: relative; display: grid; place-items: center; width: 116px; height: 116px; }
/* The trunk widens with age; rings appear from the pith outwards, a little uneven like real wood. */
.slider-rings__wood { position: relative; width: calc(44px + var(--slider-rings-v) * 70px); aspect-ratio: 1; border-radius: 48% 52% 50% 50% / 52% 48% 52% 48%; background: radial-gradient(circle, light-dark(#6d7179, #7d8087), light-dark(#cbced3, #393c41)); box-shadow: 0 0 0 4px light-dark(#ebedf0, #232529), 0 0 0 5px light-dark(#dbdde1, #2e3035), 0 10px 18px -8px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)); transition: width 0.4s cubic-bezier(0.3, 1.2, 0.5, 1); }
.slider-rings__ring { position: absolute; left: 50%; top: 50%; width: calc(var(--f) * 100%); aspect-ratio: 1; border-radius: var(--shape); border: 1px solid color-mix(in oklab, light-dark(#ebedf0, #232529) 70%, transparent); transform: translate(-50%, -50%) scale(var(--on)); opacity: var(--on); transition: transform 0.4s cubic-bezier(0.3, 1.4, 0.5, 1), opacity 0.3s, width 0.4s; }
.slider-rings__crack { position: absolute; left: 50%; top: 18%; width: 1px; height: 32%; background: color-mix(in oklab, light-dark(#f3f4f7, #1a1c1f) 60%, transparent); transform: rotate(14deg); opacity: var(--slider-rings-v); }
@media (prefers-reduced-motion: reduce) {
.slider-rings__wood, .slider-rings__ring { transition: none; }
}
.slider-rings__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; font-variant-numeric: tabular-nums; }
.slider-rings__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.slider-rings__track { position: relative; width: 100%; height: 24px; }
.slider-rings__rail { position: absolute; left: 0; right: 0; top: 10px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-rings__fill { position: absolute; left: 0; top: 10px; height: 4px; border-radius: 2px; width: calc(10px + var(--slider-rings-v) * (100% - 20px)); background: light-dark(#50535b, #c6c8cd); }
.slider-rings__thumb { position: absolute; top: 2px; left: calc(var(--slider-rings-v) * (100% - 20px)); width: 20px; height: 20px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.slider-rings:has(.slider-rings__input:focus-visible) .slider-rings__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-rings__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-rings__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; }
.slider-rings__input::-moz-range-thumb { width: 20px; height: 20px; border: 0; }function initSliderRings(root) {
var input = root.querySelector('.slider-rings__input');
var read = root.querySelector('.slider-rings__read');
var rings = root.querySelectorAll('.slider-rings__ring');
function update() {
var v = +input.value;
var t = (v - 1) / (120 - 1);
root.style.setProperty('--slider-rings-v', t);
// One ring a year, up to the stump's sixteen; each sits at its share of the radius.
var shown = Math.max(1, Math.round(t * rings.length));
rings.forEach(function (ring, k) {
ring.style.setProperty('--f', ((k + 1) / (shown + 0.6)).toFixed(3));
ring.style.setProperty('--on', k < shown ? 1 : 0);
});
var unit = v === 1 ? 'year' : 'years';
read.innerHTML = '<b>' + v + '</b> ' + unit + ' old';
input.setAttribute('aria-valuetext', v + ' ' + unit);
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-rings').forEach(initSliderRings);Goal: Create a "Rings" slider.
Visual behavior: A tree age slider drawn as a cut stump: the trunk widens with the years and growth rings appear from the pith outwards, a little uneven like real wood.
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 value is written to one CSS custom property (0 to 1) on the root; the stump's width is a calc() of it.
- Sixteen rings each get --f, their share of the radius for the number of rings showing, and --on, so new rings fade in at the centre while the older ones spread outwards.
- Each ring has its own slightly uneven border-radius, so the wood never looks drawn with a compass.
- :has(input:focus-visible) outlines the drawn thumb, so keyboard focus is visible though the input itself is not.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the rings change 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 "Rings" slider.
Visual behavior: A tree age slider drawn as a cut stump: the trunk widens with the years and growth rings appear from the pith outwards, a little uneven like real wood.
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 value is written to one CSS custom property (0 to 1) on the root; the stump's width is a calc() of it.
- Sixteen rings each get --f, their share of the radius for the number of rings showing, and --on, so new rings fade in at the centre while the older ones spread outwards.
- Each ring has its own slightly uneven border-radius, so the wood never looks drawn with a compass.
- :has(input:focus-visible) outlines the drawn thumb, so keyboard focus is visible though the input itself is not.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the rings change 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 tree age slider drawn as a cut stump: the trunk widens with the years and growth rings appear from the pith outwards, a little uneven like real wood.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, delay stagger
- Techniques
- css variables, css transition, radial gradient, border radius
- 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 words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the rings change without easing.