Drum
import { useRef, useState } from 'react';
const css = `
.field-drum { user-select: none; -webkit-user-select: none; width: 230px; }
.field-drum__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-drum__body {
position: relative;
display: flex;
justify-content: center;
gap: 4px;
height: 132px;
padding: 0 10px;
border-radius: 14px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
/* The lens: the selected row reads through a raised band. */
.field-drum__lens { position: absolute; left: 8px; right: 8px; top: 50%; height: 30px; margin-top: -15px; border-radius: 8px; background: light-dark(#ebedf0, #232529); }
.field-drum__fade { position: absolute; inset: 0; background: linear-gradient(light-dark(#f3f4f7, #1a1c1f), transparent 32%, transparent 68%, light-dark(#f3f4f7, #1a1c1f)); pointer-events: none; }
.field-drum__wheel { position: relative; width: 56px; height: 100%; perspective: 420px; cursor: ns-resize; touch-action: none; outline: none; }
.field-drum__wheel:focus-visible::after { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 30px; margin-top: -15px; border-radius: 8px; box-shadow: inset 0 0 0 2px light-dark(#5f626a, #a7abb2); }
.field-drum__wheel--ampm { width: 44px; }
.field-drum__cyl { position: absolute; left: 0; right: 0; top: 50%; height: 0; transform-style: preserve-3d; }
.field-drum__cyl[data-snap] { transition: transform 0.35s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-drum__item {
position: absolute;
left: 0;
right: 0;
top: -15px;
height: 30px;
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 19px; font-weight: 600; line-height: 30px;
font-variant-numeric: tabular-nums;
text-align: center;
backface-visibility: hidden;
}
.field-drum__colon { position: relative; align-self: center; color: light-dark(#50535b, #c6c8cd); font-family: inherit; font-size: 19px; font-weight: 600; }
@media (prefers-reduced-motion: reduce) {
.field-drum__cyl[data-snap] { transition: none; }
}
`;
const ITEM = 30;
type DrumProps = { label: string; items: string[]; loop: boolean; value: number; onChange: (i: number) => void; className?: string };
/** A 3D drum: items sit round a cylinder that turns under drag, wheel or arrow keys, then snaps to a row. */
function Drum({ label, items, loop, value, onChange, className = '' }: DrumProps) {
const n = items.length;
const step = loop ? 360 / n : 24;
// Rounded: Math.tan can differ in the last bit between server and browser, and it lands in an attribute.
const radius = Math.round((ITEM / 2 / Math.tan(((step / 2) * Math.PI) / 180)) * 100) / 100;
const [pos, setPos] = useState(value);
const [snap, setSnap] = useState(true);
const drag = useRef<{ y: number; p: number } | null>(null);
const wheelEnd = useRef(0);
const clamp = (p: number) => (loop ? p : Math.max(0, Math.min(n - 1, p)));
const settle = (p: number) => {
const r = clamp(Math.round(p));
setSnap(true);
setPos(r);
onChange(((r % n) + n) % n);
};
return (
<div
className={`field-drum__wheel ${className}`}
role="spinbutton"
tabIndex={0}
aria-label={label}
aria-valuetext={items[value]}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { y: e.clientY, p: pos };
setSnap(false);
}}
onPointerMove={(e) => {
if (!drag.current) return;
const p = drag.current.p - (e.clientY - drag.current.y) / ITEM;
// Past the ends of a finite drum it drags with resistance.
setPos(loop ? p : p < 0 ? p * 0.3 : p > n - 1 ? n - 1 + (p - n + 1) * 0.3 : p);
}}
onPointerUp={() => {
if (!drag.current) return;
drag.current = null;
settle(pos);
}}
onWheel={(e) => {
setSnap(false);
const p = clamp(pos + e.deltaY / 60);
setPos(p);
window.clearTimeout(wheelEnd.current);
wheelEnd.current = window.setTimeout(() => settle(p), 120);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
settle(Math.round(pos) + (e.key === 'ArrowDown' ? 1 : -1));
}
}}
>
<div className="field-drum__cyl" data-snap={snap || undefined} style={{ transform: `translateZ(${-radius}px) rotateX(${pos * step}deg)` }}>
{items.map((t, i) => {
// Nearest copy of this item to the current position, so a looping drum never shows a seam.
const k = loop ? i + Math.round((pos - i) / n) * n : i;
const off = Math.abs(k - pos) * step;
return (
<div
key={i}
className="field-drum__item"
aria-hidden="true"
style={{ transform: `rotateX(${-k * step}deg) translateZ(${radius}px)`, opacity: off > 80 ? 0 : Number((1 - off / 110).toFixed(3)) }}
>
{t}
</div>
);
})}
</div>
</div>
);
}
const HOURS = Array.from({ length: 12 }, (_, i) => String(i + 1));
const MINUTES = Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, '0'));
const AMPM = ['AM', 'PM'];
export function DrumInput() {
const [h, setH] = useState(6);
const [m, setM] = useState(6);
const [p, setP] = useState(0);
return (
<>
<style>
{css}
</style>
<div className="field-drum" role="group" aria-labelledby="field-drum-label">
<span id="field-drum-label" className="field-drum__label">
Alarm · {HOURS[h]}:{MINUTES[m]} {AMPM[p]}
</span>
<div className="field-drum__body">
<span className="field-drum__lens" />
<Drum label="Hour" items={HOURS} loop value={h} onChange={setH} />
<span className="field-drum__colon" aria-hidden="true">
:
</span>
<Drum label="Minute" items={MINUTES} loop value={m} onChange={setM} />
<Drum label="AM or PM" items={AMPM} loop={false} value={p} onChange={setP} className="field-drum__wheel--ampm" />
<span className="field-drum__fade" />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldDrum(el) on the mounted element instead.
<div class="field-drum" role="group" aria-labelledby="field-drum-label">
<span id="field-drum-label" class="field-drum__label">Alarm · 7:30 AM</span>
<div class="field-drum__body">
<span class="field-drum__lens"></span>
<div class="field-drum__wheel " role="spinbutton" tabindex="0" aria-label="Hour" aria-valuetext="7">
<div class="field-drum__cyl" data-snap="true" style="transform:translateZ(-55.98px) rotateX(180deg)">
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-360deg) translateZ(55.98px);opacity:0">1</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-30deg) translateZ(55.98px);opacity:0">2</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-60deg) translateZ(55.98px);opacity:0">3</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-90deg) translateZ(55.98px);opacity:0">4</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-120deg) translateZ(55.98px);opacity:0.455">5</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-150deg) translateZ(55.98px);opacity:0.727">6</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-180deg) translateZ(55.98px);opacity:1">7</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-210deg) translateZ(55.98px);opacity:0.727">8</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-240deg) translateZ(55.98px);opacity:0.455">9</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-270deg) translateZ(55.98px);opacity:0">10</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-300deg) translateZ(55.98px);opacity:0">11</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-330deg) translateZ(55.98px);opacity:0">12</div>
</div>
</div>
<span class="field-drum__colon" aria-hidden="true">:</span>
<div class="field-drum__wheel " role="spinbutton" tabindex="0" aria-label="Minute" aria-valuetext="30">
<div class="field-drum__cyl" data-snap="true" style="transform:translateZ(-55.98px) rotateX(180deg)">
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-360deg) translateZ(55.98px);opacity:0">00</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-30deg) translateZ(55.98px);opacity:0">05</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-60deg) translateZ(55.98px);opacity:0">10</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-90deg) translateZ(55.98px);opacity:0">15</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-120deg) translateZ(55.98px);opacity:0.455">20</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-150deg) translateZ(55.98px);opacity:0.727">25</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-180deg) translateZ(55.98px);opacity:1">30</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-210deg) translateZ(55.98px);opacity:0.727">35</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-240deg) translateZ(55.98px);opacity:0.455">40</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-270deg) translateZ(55.98px);opacity:0">45</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-300deg) translateZ(55.98px);opacity:0">50</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-330deg) translateZ(55.98px);opacity:0">55</div>
</div>
</div>
<div class="field-drum__wheel field-drum__wheel--ampm" role="spinbutton" tabindex="0" aria-label="AM or PM" aria-valuetext="AM">
<div class="field-drum__cyl" data-snap="true" style="transform:translateZ(-70.57px) rotateX(0deg)">
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(0deg) translateZ(70.57px);opacity:1">AM</div>
<div class="field-drum__item" aria-hidden="true" style="transform:rotateX(-24deg) translateZ(70.57px);opacity:0.782">PM</div>
</div>
</div>
<span class="field-drum__fade"></span>
</div>
</div>.field-drum { user-select: none; -webkit-user-select: none; width: 230px; }
.field-drum__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-drum__body {
position: relative;
display: flex;
justify-content: center;
gap: 4px;
height: 132px;
padding: 0 10px;
border-radius: 14px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
}
/* The lens: the selected row reads through a raised band. */
.field-drum__lens { position: absolute; left: 8px; right: 8px; top: 50%; height: 30px; margin-top: -15px; border-radius: 8px; background: light-dark(#ebedf0, #232529); }
.field-drum__fade { position: absolute; inset: 0; background: linear-gradient(light-dark(#f3f4f7, #1a1c1f), transparent 32%, transparent 68%, light-dark(#f3f4f7, #1a1c1f)); pointer-events: none; }
.field-drum__wheel { position: relative; width: 56px; height: 100%; perspective: 420px; cursor: ns-resize; touch-action: none; outline: none; }
.field-drum__wheel:focus-visible::after { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 30px; margin-top: -15px; border-radius: 8px; box-shadow: inset 0 0 0 2px light-dark(#5f626a, #a7abb2); }
.field-drum__wheel--ampm { width: 44px; }
.field-drum__cyl { position: absolute; left: 0; right: 0; top: 50%; height: 0; transform-style: preserve-3d; }
.field-drum__cyl[data-snap] { transition: transform 0.35s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-drum__item {
position: absolute;
left: 0;
right: 0;
top: -15px;
height: 30px;
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 19px; font-weight: 600; line-height: 30px;
font-variant-numeric: tabular-nums;
text-align: center;
backface-visibility: hidden;
}
.field-drum__colon { position: relative; align-self: center; color: light-dark(#50535b, #c6c8cd); font-family: inherit; font-size: 19px; font-weight: 600; }
@media (prefers-reduced-motion: reduce) {
.field-drum__cyl[data-snap] { transition: none; }
}function initFieldDrum(root) {
var wheels = root.querySelectorAll('.field-drum__wheel');
var label = root.querySelector('.field-drum__label');
if (wheels.length !== 3 || !label) return;
var ITEM = 30;
var values = [];
var texts = [];
function updateLabel() {
label.textContent = 'Alarm · ' + texts[0][values[0]] + ':' + texts[1][values[1]] + ' ' + texts[2][values[2]];
}
// A 3D drum: items sit round a cylinder that turns under drag, wheel or arrow keys, then snaps to a row.
function drum(wheel, w) {
var cyl = wheel.querySelector('.field-drum__cyl');
var items = wheel.querySelectorAll('.field-drum__item');
var n = items.length;
var loop = !wheel.classList.contains('field-drum__wheel--ampm');
var step = loop ? 360 / n : 24;
var radius = Math.round((ITEM / 2 / Math.tan(((step / 2) * Math.PI) / 180)) * 100) / 100;
texts[w] = Array.prototype.map.call(items, function (it) { return it.textContent; });
values[w] = Math.max(0, texts[w].indexOf(wheel.getAttribute('aria-valuetext')));
var pos = values[w];
var drag = null;
var wheelEnd = 0;
function clamp(p) { return loop ? p : Math.max(0, Math.min(n - 1, p)); }
function paint(snap) {
cyl.toggleAttribute('data-snap', snap);
cyl.style.transform = 'translateZ(' + -radius + 'px) rotateX(' + pos * step + 'deg)';
items.forEach(function (it, i) {
// Nearest copy of this item to the current position, so a looping drum never shows a seam.
var k = loop ? i + Math.round((pos - i) / n) * n : i;
var off = Math.abs(k - pos) * step;
it.style.transform = 'rotateX(' + -k * step + 'deg) translateZ(' + radius + 'px)';
it.style.opacity = off > 80 ? '0' : (1 - off / 110).toFixed(3);
});
}
function settle(p) {
pos = clamp(Math.round(p));
values[w] = ((pos % n) + n) % n;
wheel.setAttribute('aria-valuetext', texts[w][values[w]]);
updateLabel();
paint(true);
}
function down(e) {
wheel.setPointerCapture(e.pointerId);
drag = { y: e.clientY, p: pos };
}
function move(e) {
if (!drag) return;
var p = drag.p - (e.clientY - drag.y) / ITEM;
// Past the ends of a finite drum it drags with resistance.
pos = loop ? p : p < 0 ? p * 0.3 : p > n - 1 ? n - 1 + (p - n + 1) * 0.3 : p;
paint(false);
}
function up() {
if (!drag) return;
drag = null;
settle(pos);
}
function onWheel(e) {
e.preventDefault();
pos = clamp(pos + e.deltaY / 60);
paint(false);
clearTimeout(wheelEnd);
wheelEnd = setTimeout(function () { settle(pos); }, 120);
}
function key(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
settle(Math.round(pos) + (e.key === 'ArrowDown' ? 1 : -1));
}
}
wheel.addEventListener('pointerdown', down);
wheel.addEventListener('pointermove', move);
wheel.addEventListener('pointerup', up);
wheel.addEventListener('wheel', onWheel, { passive: false });
wheel.addEventListener('keydown', key);
paint(true);
return function () {
clearTimeout(wheelEnd);
wheel.removeEventListener('pointerdown', down);
wheel.removeEventListener('pointermove', move);
wheel.removeEventListener('pointerup', up);
wheel.removeEventListener('wheel', onWheel);
wheel.removeEventListener('keydown', key);
};
}
var cleanups = Array.prototype.map.call(wheels, drum);
return function () { cleanups.forEach(function (c) { c(); }); };
}
document.querySelectorAll('.field-drum').forEach(initFieldDrum);Goal: Create a "Drum" input.
Visual behavior: An alarm-time picker made of 3D drums: hour, minute and AM/PM wheels you drag, scroll or arrow through. The numbers sit round real cylinders that turn under a raised lens and snap to the nearest row.
Technique:
- Each item is rotateX(-k x step) translateZ(radius), where radius = 15 / tan(step / 2) for a 30px row, so the items sit edge to edge round a cylinder; the cylinder itself is translateZ(-radius) rotateX(pos x step).
- Hours and minutes have twelve items (30deg each) and loop: every item is drawn at whichever copy is nearest the current position, so there is no seam. AM/PM is a short finite drum with rubber-band ends.
- Drag maps 30px to one row; release, wheel (after a 120ms pause) and arrow keys snap with a short transition. Items fade with their angle from the front.
Accessibility: Each drum is role="spinbutton" with an aria-label and aria-valuetext; Up and Down step it. The group label states the time. Under prefers-reduced-motion it still works, with plain snapping.
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 "Drum" input.
Visual behavior: An alarm-time picker made of 3D drums: hour, minute and AM/PM wheels you drag, scroll or arrow through. The numbers sit round real cylinders that turn under a raised lens and snap to the nearest row.
Technique:
- Each item is rotateX(-k x step) translateZ(radius), where radius = 15 / tan(step / 2) for a 30px row, so the items sit edge to edge round a cylinder; the cylinder itself is translateZ(-radius) rotateX(pos x step).
- Hours and minutes have twelve items (30deg each) and loop: every item is drawn at whichever copy is nearest the current position, so there is no seam. AM/PM is a short finite drum with rubber-band ends.
- Drag maps 30px to one row; release, wheel (after a 120ms pause) and arrow keys snap with a short transition. Items fade with their angle from the front.
Accessibility: Each drum is role="spinbutton" with an aria-label and aria-valuetext; Up and Down step it. The group label states the time. Under prefers-reduced-motion it still works, with plain snapping.
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
An alarm-time picker made of 3D drums: hour, minute and AM/PM wheels you drag, scroll or arrow through. The numbers sit round real cylinders that turn under a raised lens and snap to the nearest row.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, 3d, spring, state change
- Techniques
- preserve 3d, 3d transform, event position, perspective
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Each drum is role="spinbutton" with an aria-label and aria-valuetext; Up and Down step it. The group label states the time. Under prefers-reduced-motion it still works, with plain snapping.