Steep
import { useState } from 'react';
const WORDS = ['Weak', 'Light', 'Medium', 'Strong', 'Bold'];
const css = `
@keyframes slider-steep-bob { 50% { transform: translateY(4px) rotate(3deg); } }
@keyframes slider-steep-curl { from { transform: translate(0, 0) scale(0.6); opacity: 0; } 30% { opacity: calc(var(--slider-steep-v) * 0.9); } to { transform: translate(var(--dx), 1px) scale(1.6, 0.9); opacity: 0; } }
@keyframes slider-steep-steam { from { transform: translateY(6px); opacity: 0; } 40% { opacity: 0.7; } to { transform: translateY(-16px); opacity: 0; } }
.slider-steep { display: grid; justify-items: center; gap: 8px; width: 220px; }
.slider-steep__scene { position: relative; width: 150px; height: 130px; }
.slider-steep__scene > svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.slider-steep__cup { fill: light-dark(#42454c, #e5e6e9); }
.slider-steep__saucer { fill: light-dark(#5f626a, #a7abb2); }
.slider-steep__rim { fill: light-dark(#50535b, #c6c8cd); }
.slider-steep__handle { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 6; }
.slider-steep__tea { fill: color-mix(in oklab, light-dark(#ebedf0, #232529) calc(var(--slider-steep-v) * 100%), light-dark(#5f626a, #a7abb2)); transition: fill 0.4s; }
.slider-steep__steam { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 2; stroke-linecap: round; animation: slider-steep-steam 2.2s ease-out infinite; }
.slider-steep__bag { transform-box: view-box; transform-origin: 96px 8px; animation: slider-steep-bob 2.4s ease-in-out infinite; }
.slider-steep__string { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; }
.slider-steep__pouch { fill: light-dark(#5f626a, #a7abb2); }
.slider-steep__tag { fill: light-dark(#373a40, #f1f2f3); }
.slider-steep__curl { position: absolute; width: 16px; height: 6px; border-radius: 50%; background: radial-gradient(closest-side, light-dark(#dbdde1, #2e3035), transparent); animation: slider-steep-curl 2s ease-out infinite; }
.slider-steep__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.slider-steep__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.slider-steep__track { position: relative; width: 100%; height: 24px; }
.slider-steep__rail { position: absolute; left: 0; right: 0; top: 8px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087), light-dark(#ebedf0, #232529)); }
.slider-steep__thumb { position: absolute; top: 2px; left: calc(var(--slider-steep-v) * (100% - 20px)); width: 20px; height: 20px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.slider-steep:has(.slider-steep__input:focus-visible) .slider-steep__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; }
.slider-steep__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-steep__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; }
.slider-steep__input::-moz-range-thumb { width: 20px; height: 20px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-steep * { animation: none !important; }
}
`;
export function SteepSlider() {
const [v, setV] = useState(50);
const word = WORDS[Math.min(4, Math.floor(v / 20))];
const mins = 1 + Math.round((v / 100) * 5);
return (
<>
<style>
{css}
</style>
<div className="slider-steep" style={{ '--slider-steep-v': v / 100 } as React.CSSProperties}>
<span className="slider-steep__scene" aria-hidden="true">
<svg viewBox="0 0 150 130">
<path className="slider-steep__steam" d="M52 34c-4-6 4-10 0-16" />
<path className="slider-steep__steam" d="M66 34c-4-6 4-10 0-16" style={{ animationDelay: '-1.1s' }} />
<path className="slider-steep__handle" d="M112 62c14 0 14 26 0 26" />
<path className="slider-steep__cup" d="M24 46h92l-8 58a12 12 0 0 1-12 10H44a12 12 0 0 1-12-10Z" />
<ellipse className="slider-steep__rim" cx="70" cy="46" rx="46" ry="8" />
<ellipse className="slider-steep__tea" cx="70" cy="47" rx="41" ry="5.5" />
<ellipse className="slider-steep__saucer" cx="70" cy="118" rx="56" ry="6" />
<g className="slider-steep__bag">
<path className="slider-steep__string" d="M96 8C92 22 90 34 86 47" />
<rect className="slider-steep__tag" x="91" y="2" width="12" height="9" rx="1.5" />
</g>
</svg>
<span className="slider-steep__curl" style={{ left: 80, top: 44, '--dx': '-26px' } as React.CSSProperties} />
<span className="slider-steep__curl" style={{ left: 84, top: 45, '--dx': '12px', animationDelay: '-0.7s' } as React.CSSProperties} />
<span className="slider-steep__curl" style={{ left: 76, top: 46, '--dx': '-40px', animationDelay: '-1.4s' } as React.CSSProperties} />
</span>
<span className="slider-steep__read" aria-hidden="true">
<b>{word}</b> · {mins} min
</span>
<div className="slider-steep__track">
<span className="slider-steep__rail" />
<span className="slider-steep__thumb" />
<input
className="slider-steep__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Tea strength"
aria-valuetext={`${word}, ${mins} minutes`}
/>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderSteep(el) on the mounted element instead.
<div class="slider-steep" style="--slider-steep-v:0.5">
<span class="slider-steep__scene" aria-hidden="true">
<svg viewBox="0 0 150 130">
<path class="slider-steep__steam" d="M52 34c-4-6 4-10 0-16"></path>
<path class="slider-steep__steam" d="M66 34c-4-6 4-10 0-16" style="animation-delay:-1.1s"></path>
<path class="slider-steep__handle" d="M112 62c14 0 14 26 0 26"></path>
<path class="slider-steep__cup" d="M24 46h92l-8 58a12 12 0 0 1-12 10H44a12 12 0 0 1-12-10Z"></path>
<ellipse class="slider-steep__rim" cx="70" cy="46" rx="46" ry="8"></ellipse>
<ellipse class="slider-steep__tea" cx="70" cy="47" rx="41" ry="5.5"></ellipse>
<ellipse class="slider-steep__saucer" cx="70" cy="118" rx="56" ry="6"></ellipse>
<g class="slider-steep__bag">
<path class="slider-steep__string" d="M96 8C92 22 90 34 86 47"></path>
<rect class="slider-steep__tag" x="91" y="2" width="12" height="9" rx="1.5"></rect>
</g>
</svg>
<span class="slider-steep__curl" style="left:80px;top:44px;--dx:-26px"></span>
<span class="slider-steep__curl" style="left:84px;top:45px;--dx:12px;animation-delay:-0.7s"></span>
<span class="slider-steep__curl" style="left:76px;top:46px;--dx:-40px;animation-delay:-1.4s"></span>
</span>
<span class="slider-steep__read" aria-hidden="true"><b>Medium</b> · 4 min</span>
<div class="slider-steep__track">
<span class="slider-steep__rail"></span>
<span class="slider-steep__thumb"></span>
<input class="slider-steep__input" type="range" min="0" max="100" aria-label="Tea strength" aria-valuetext="Medium, 4 minutes" value="50"/>
</div>
</div>@keyframes slider-steep-bob { 50% { transform: translateY(4px) rotate(3deg); } }
@keyframes slider-steep-curl { from { transform: translate(0, 0) scale(0.6); opacity: 0; } 30% { opacity: calc(var(--slider-steep-v) * 0.9); } to { transform: translate(var(--dx), 1px) scale(1.6, 0.9); opacity: 0; } }
@keyframes slider-steep-steam { from { transform: translateY(6px); opacity: 0; } 40% { opacity: 0.7; } to { transform: translateY(-16px); opacity: 0; } }
.slider-steep { display: grid; justify-items: center; gap: 8px; width: 220px; }
.slider-steep__scene { position: relative; width: 150px; height: 130px; }
.slider-steep__scene > svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.slider-steep__cup { fill: light-dark(#42454c, #e5e6e9); }
.slider-steep__saucer { fill: light-dark(#5f626a, #a7abb2); }
.slider-steep__rim { fill: light-dark(#50535b, #c6c8cd); }
.slider-steep__handle { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 6; }
.slider-steep__tea { fill: color-mix(in oklab, light-dark(#ebedf0, #232529) calc(var(--slider-steep-v) * 100%), light-dark(#5f626a, #a7abb2)); transition: fill 0.4s; }
.slider-steep__steam { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 2; stroke-linecap: round; animation: slider-steep-steam 2.2s ease-out infinite; }
.slider-steep__bag { transform-box: view-box; transform-origin: 96px 8px; animation: slider-steep-bob 2.4s ease-in-out infinite; }
.slider-steep__string { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; }
.slider-steep__pouch { fill: light-dark(#5f626a, #a7abb2); }
.slider-steep__tag { fill: light-dark(#373a40, #f1f2f3); }
.slider-steep__curl { position: absolute; width: 16px; height: 6px; border-radius: 50%; background: radial-gradient(closest-side, light-dark(#dbdde1, #2e3035), transparent); animation: slider-steep-curl 2s ease-out infinite; }
.slider-steep__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.slider-steep__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.slider-steep__track { position: relative; width: 100%; height: 24px; }
.slider-steep__rail { position: absolute; left: 0; right: 0; top: 8px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087), light-dark(#ebedf0, #232529)); }
.slider-steep__thumb { position: absolute; top: 2px; left: calc(var(--slider-steep-v) * (100% - 20px)); width: 20px; height: 20px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 0 3px light-dark(#f3f4f7, #1a1c1f), 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.slider-steep:has(.slider-steep__input:focus-visible) .slider-steep__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; }
.slider-steep__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-steep__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; }
.slider-steep__input::-moz-range-thumb { width: 20px; height: 20px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-steep * { animation: none !important; }
}function initSliderSteep(root) {
var WORDS = ['Weak', 'Light', 'Medium', 'Strong', 'Bold'];
var input = root.querySelector('.slider-steep__input');
var read = root.querySelector('.slider-steep__read');
var word = read.querySelector('b');
function update() {
var v = +input.value;
var w = WORDS[Math.min(4, Math.floor(v / 20))];
var mins = 1 + Math.round((v / 100) * 5);
root.style.setProperty('--slider-steep-v', v / 100);
word.textContent = w;
read.replaceChildren(word, ' · ' + mins + ' min');
input.setAttribute('aria-valuetext', w + ', ' + mins + ' minutes');
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-steep').forEach(initSliderSteep);Goal: Create a "Steep" slider.
Visual behavior: A tea-strength slider: the bag bobs in the cup, colour curls out of it into the water, steam rises and the strength word and steeping time change below.
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 tea surface is mixed from a light to a deep shade with color-mix() by it, and the colour curls and steam fade in with it.
- The bag bobs, the curls drift and the steam rises on looping keyframes with staggered negative delays.
- The word (Weak to Bold) and the minutes come from the value and together form the aria-valuetext.
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 bag, the curls and the steam hold still.
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 "Steep" slider.
Visual behavior: A tea-strength slider: the bag bobs in the cup, colour curls out of it into the water, steam rises and the strength word and steeping time change below.
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 tea surface is mixed from a light to a deep shade with color-mix() by it, and the colour curls and steam fade in with it.
- The bag bobs, the curls drift and the steam rises on looping keyframes with staggered negative delays.
- The word (Weak to Bold) and the minutes come from the value and together form the aria-valuetext.
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 bag, the curls and the steam hold still.
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 tea-strength slider: the bag bobs in the cup, colour curls out of it into the water, steam rises and the strength word and steeping time change below.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, loop, ambient
- Techniques
- css variables, keyframes, svg
- 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 bag, the curls and the steam hold still.