Library / Tutorial

Glass date-range picker,
step by step.

Three months of liquid glass in an arc over a sunset. Pick two dates and molten amber pours from a glass tube through the days between them, one continuous ribbon, like water. A real keyboard grid, click, tap and drag ranges, swipe to turn the month. Four stages, each a live page you can run.

4Stages
1Canvas
0Libraries

This is the whole build of the glass date-range picker, in the order it was written. Three months of liquid glass stand in a shallow arc over a sunset. Pick a first date and it lights violet; move to the last and molten amber pours out of a glass tube beside the calendar and runs through the days you chose, week by week, like water in a channel. Each stage below is the page as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.

There is no framework and no build step: one HTML page, one style sheet, three ES modules and one photo. The glass, the tiles and the liquid are one WebGL 2 canvas behind the calendar; every day, title and button is real DOM above it, so the keyboard, screen readers and text selection all work as they do on any page.

1 · One month, in HTML and CSS

Start with the month itself, written out by hand: a title, a weekday row and five weeks of day tiles. Blank tiles fill the days before the 1st. The range is plain classes for now: is-start, in-range and is-end. Each day carries its day number (days since 1 January 1970), which the script will read later.

index.html — the week of the 14th
<div class="row">
  <div class="cell" data-dn="20737"><span class="num">11</span></div>
  <div class="cell" data-dn="20738"><span class="num">12</span></div>
  <div class="cell" data-dn="20739"><span class="num">13</span></div>
  <div class="cell in-range is-start" data-dn="20740"><span class="num">14</span></div>
  <div class="cell in-range" data-dn="20741"><span class="num">15</span></div>
  <div class="cell in-range" data-dn="20742"><span class="num">16</span></div>
  <div class="cell in-range" data-dn="20743"><span class="num">17</span></div>
</div>
index.html — the readout
<div class="foot"><span class="sel"><b>Selected:</b> Oct 14 – 21 <i>· 8 days</i></span></div>

The sunset is a photo behind everything.

app.css — the backdrop
.stage { position: relative; width: 100%; min-height: 100svh; overflow: hidden; touch-action: pan-y; user-select: none; -webkit-user-select: none;
  background: #3b1a3c url(../img/wallpaper-1376.webp) center / cover no-repeat; }

Until the WebGL glass arrives in stage 3, the pane is frosted in CSS. The finished page keeps this rule as its fallback: it holds whenever html does not have the gl class, so a browser without WebGL 2 sees exactly this.

app.css — the CSS frost
html:not(.gl) .pane { background: rgba(255, 255, 255, .13); border: 1px solid rgba(255, 255, 255, .42);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45), 0 24px 60px rgba(40, 8, 40, .35); backdrop-filter: blur(26px) saturate(1.25) brightness(.82); -webkit-backdrop-filter: blur(26px) saturate(1.25) brightness(.82); }
app.css — a day tile
.cell { position: relative; width: var(--cell); height: var(--cell); display: grid; place-items: center; border-radius: calc(10px * var(--sc));
  font-size: calc(18px * var(--sc)); font-weight: 400; font-variant-numeric: tabular-nums; color: var(--ink); cursor: pointer; outline: none;
  -webkit-tap-highlight-color: transparent; text-shadow: 0 1px 3px rgba(60, 14, 40, .5); }

The range is CSS too: amber days and violet glass at the two ends.

app.css — the range, in CSS
html:not(.gl) .cell[data-dn] { background: rgba(255, 255, 255, .07); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .22); }
html:not(.gl) .cell.blank { background: rgba(255, 255, 255, .03); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .1); }
html:not(.gl) .cell.in-range { background: linear-gradient(180deg, #ffc27a, #f08a3a 60%, #d2601f); box-shadow: 0 0 14px rgba(255, 140, 60, .55), inset 0 1px 0 rgba(255, 240, 210, .7); }
html:not(.gl) .cell.is-start, html:not(.gl) .cell.is-end { background: linear-gradient(180deg, #8f84ff, #4a3ad8); box-shadow: 0 0 16px rgba(110, 90, 255, .75), inset 0 0 0 1px rgba(210, 205, 255, .85); }

Every size in the sheet is a custom property: the tile, the gap, the padding, the rows. From stage 2 on, the script measures the screen and sets them. With no script yet, a small style block in the head sets them, with a tighter set for phones.

index.html — the sizes, for now
.stage { --sc: 1; --cell: 54px; --gap: 7px; --pad: 24px; --top: 19px; --headH: 69px; --wdH: 30px; --footGap: 14px; --footH: 57px; --r: 26px;
  --w: calc(2 * var(--pad) + 7 * var(--cell) + 6 * var(--gap));
  --h: calc(var(--top) + var(--headH) + var(--wdH) + 5 * (var(--cell) + var(--gap)) - var(--gap) + var(--footGap) + var(--footH)); }
.pane { left: calc(50% - var(--w) / 2); top: calc(50% - var(--h) / 2); height: var(--h); }
.nav { left: calc(50% + var(--w) / 2 - var(--pad) + 10px * var(--sc) - 88px); top: calc(50% - var(--h) / 2 + var(--top) + (var(--headH) - 44px) / 2); }
@media (max-width: 639px) {
  .stage { --sc: .85; --gap: 4px; --pad: 10px; --cell: calc((100vw - 28px - 2 * var(--pad) - 6 * var(--gap)) / 7);
    --top: 17px; --headH: 59px; --wdH: 26px; --footGap: 12px; --footH: 49px; --r: 22px; }
}
Stage 1 · HTML and CSSOpen ↗
One month of frosted glass over a violet and orange sunset: October 2026, Sun to Sat, the 14th and 21st violet, the days between amber, Selected: Oct 14 – 21 · 8 days below.

2 · A real date-range picker

Now the modules. All the calendar maths works on whole day numbers, counted in UTC, so daylight saving can never move a date. The day of the week falls out of the number, because 1 January 1970 was a Thursday.

cal.js — day numbers
export const DAY = 86400000;
export const dn = (y, m, d) => Math.round(Date.UTC(y, m, d) / DAY);
export const ymd = (n) => { const t = new Date(n * DAY); return { y: t.getUTCFullYear(), m: t.getUTCMonth(), d: t.getUTCDate() }; };
export const dow = (n) => ((n + 4) % 7 + 7) % 7;                 // 0 = Sunday (1970-01-01 was a Thursday)
cal.js — one month
export function monthGrid(mi) {
  const { y, m } = ym(mi);
  const first = dn(y, m, 1), lead = dow(first), days = daysIn(y, m);
  return { mi, y, m, first, lead, days, rows: Math.ceil((lead + days) / 7), last: first + days - 1 };
}

A pane is built from that grid. Each day is a gridcell with its full date as its label, and only one day in the grid is in the tab order at a time (a roving tabindex).

app.js — the day tiles
for (let r = 0; r < g.rows; r++) {
  const row = document.createElement('div'); row.className = 'row'; row.setAttribute('role', 'row');
  for (let c = 0; c < 7; c++) {
    const idx = r * 7 + c, day = idx - g.lead + 1;
    const cell = document.createElement('div');
    cell.setAttribute('role', 'gridcell');
    if (day >= 1 && day <= g.days) {
      const n = g.first + day - 1;
      cell.className = 'cell'; cell.dataset.dn = n; cell.tabIndex = -1; cell._n = n; cell._base = C.longDate(n) + (n === TODAY ? ', today' : ''); cell._st = '';
      cell.innerHTML = `<span class="num">${day}</span>`;
      if (n === TODAY) cell.setAttribute('aria-current', 'date');
      this.cells[idx] = cell;
    } else cell.className = 'cell blank';
    row.append(cell);
  }
  rows.append(row);
}

Picking is two steps. The first pick is the anchor; the second closes the range, whichever way round. Both are announced in a live region, and the finished range goes out as a rangechange event.

app.js — pick
function pick(n) {
  if (S.anchor == null) {
    S.saved = { lo: S.lo, hi: S.hi, origin: S.origin };
    S.anchor = n; S.preview = n;
    announce(`Range start ${C.longDate(n)}. Choose an end date.`);
  } else {
    const a = S.anchor;
    S.lo = Math.min(a, n); S.hi = Math.max(a, n); S.origin = a; S.anchor = null; S.preview = null; S.saved = null;
    const days = S.hi - S.lo + 1;
    announce(`Selected ${C.longDate(S.lo)} to ${C.longDate(S.hi)}, ${days} day${days > 1 ? 's' : ''}.`);
    stage.dispatchEvent(new CustomEvent('rangechange', { bubbles: true, detail: { start: C.iso(S.lo), end: C.iso(S.hi) } }));
  }
  syncSelection(); kick();
}

One pass draws the state onto every day: in the range or not, the two ends, the ring on the end you are hovering while you pick, and aria-selected.

app.js — the state on each day
const inR = n >= lo && n <= hi, isS = pending ? n === S.anchor : n === lo, isE = !pending && n === hi;
const ring = pending && n === S.preview && n !== S.anchor;
const tab = n === S.focus && isFrontPane;
const key = `${+inR}${+isS}${+isE}${+ring}${+tab}${+pending}${+(lo === hi)}`;
if (key === cell._st) continue;                     // nothing changed for this day
cell._st = key;
cell.classList.toggle('in-range', inR); cell.classList.toggle('is-start', isS); cell.classList.toggle('is-end', isE);
cell.classList.toggle('is-ring', ring);
cell.setAttribute('aria-selected', pending ? String(n === S.anchor) : String(inR));
app.css — the end you are about to pick
html:not(.gl) .cell.is-ring { box-shadow: inset 0 0 0 2px rgba(190, 180, 255, .9), 0 0 12px rgba(110, 90, 255, .5); }

The keyboard follows the ARIA grid pattern: arrows by day and week, Home and End to the ends of the week, Page Up and Page Down by month, with Shift by year, Enter or Space to pick and Escape to cancel. Moving past the edge of the month turns to the next one, and while you pick, the focus previews the end.

app.js — the keyboard
panesEl.addEventListener('keydown', (e) => {
  const cell = e.target.closest?.('.cell[data-dn]'); if (!cell) return;
  const n = +cell.dataset.dn, d = C.dow(n);
  let to = null;
  switch (e.key) {
    case 'ArrowLeft': to = n - 1; break;
    case 'ArrowRight': to = n + 1; break;
    case 'ArrowUp': to = n - 7; break;
    case 'ArrowDown': to = n + 7; break;
    case 'Home': to = n - d; break;
    case 'End': to = n + (6 - d); break;
    case 'PageUp': to = C.addMonths(n, e.shiftKey ? -12 : -1); break;
    case 'PageDown': to = C.addMonths(n, e.shiftKey ? 12 : 1); break;
    case 'Enter': case ' ': e.preventDefault(); pick(n); return;
    case 'Escape': if (S.anchor != null) { e.preventDefault(); cancelPick(); } return;
    default: return;
  }
  e.preventDefault(); moveFocus(to);
});
app.js — moving the focus
function moveFocus(n) {
  const mi = C.mOf(n);
  S.focus = n;
  if (mi !== S.month) goMonth(mi - S.month, { focusCell: true, keepFocusDate: true });
  else { syncSelection(); focusDate(); }
  setPreview(n);
}

With a mouse you can click twice or press, drag and release. A tap works like a click.

app.js — click, drag, tap
addEventListener('pointerup', (e) => {
  const D = UI.down; UI.down = null;
  if (!D) { kick(); return; }
  const cell = D.cell;
  if (!cell) return;
  const n = +cell.dataset.dn;
  if (D.type === 'mouse') {
    const up = cellAt(e.clientX, e.clientY);
    if (D.started) { if (up && +up.dataset.dn !== n) pick(+up.dataset.dn); }      // press-drag-release
    else pick(+(up?.dataset.dn ?? n));                                             // second click
  } else { S.focus = n; pick(n); }
  kick();
});

The readout says the range the short way: “Oct 14 – 21”, “Oct 28 – Nov 3”, and the years only when they differ.

cal.js — the range label
export function rangeLabel(a, b) {
  const A = ymd(a), B = ymd(b), ma = fShort.format(at(a)), mb = fShort.format(at(b));
  if (a === b) return `${ma} ${A.d}`;
  if (A.y !== B.y) return `${ma} ${A.d}, ${A.y} – ${mb} ${B.d}, ${B.y}`;
  if (A.m !== B.m) return `${ma} ${A.d} – ${mb} ${B.d}`;
  return `${ma} ${A.d} – ${B.d}`;
}
Stage 2 · a real pickerOpen ↗
The CSS glass month while picking: the 8th lit violet, a violet ring on the 24th, the days between amber, Selected: Oct 8 – 24 · 17 days in the footer.

3 · The glass, in WebGL

The neighbouring months join the current one in a concave arc. A pane's place on the arc is one number, its distance from the front, which gives a sideways offset, a depth and a turn about the vertical axis. Projecting its four corners gives the quad it fills on screen.

app.js — a pane on the arc
function pose(s) {
  const u = Math.abs(s), sg = Math.sign(s);
  let X, Z, th, a;
  if (u <= 1) {
    X = M.Xs * Math.sin(u * Math.PI / 2); Z = M.Zs * (1 - Math.cos(u * Math.PI / 2));
    th = M.A * (u + 0.32 * Math.sin(u * Math.PI));
    a = M.sideA ? 1 : 1 - smooth(0.2, 0.8, u);
  } else {
    const v = Math.min(u - 1, 1.6);
    X = M.Xs * (1 + 0.6 * v); Z = M.Zs * (1 + 1.1 * v); th = M.A * (1 + 0.45 * v);
    a = M.sideA ? 1 - smooth(0.1, 0.7, v) : 0;
  }
  if (sg > 0) X += M.Xextra * Math.min(u, 1);
  return { X: sg * X, Z, th: sg * th, a };
}
app.js — project a point
function project(ps, w, lx, ly) {
  const rx = lx - w / 2, ry = ly - M.h6 / 2, c = Math.cos(ps.th), s = Math.sin(ps.th);
  const x3 = ps.X + rx * c, z3 = ps.Z + rx * s;
  const k = M.F / (M.F - z3);
  return [M.cx + x3 * k, M.cy + ry * k];
}

The DOM pane is put on that quad with a CSS matrix3d, and the shader gets the very same matrix, so the glass and the numbers on it can never drift apart. At rest the front pane is a plain 2D translate, so its text stays crisp.

app.js — the same matrix for the DOM and the glass
const q = [project(ps, M.w, 0, 0), project(ps, M.w, M.w, 0), project(ps, M.w, M.w, h), project(ps, M.w, 0, h)];
H = homography(q, M.w, h);
const t = `matrix3d(${f2(H[0])},${f2(H[3])},0,${f2(H[6])},${f2(H[1])},${f2(H[4])},0,${f2(H[7])},0,0,1,0,${f2(H[2])},${f2(H[5])},0,${f2(H[8])})`;
if (pn.tf !== t) { pn.tf = t; pn.el.style.transform = t; }

Turning the month moves the arc on a damped spring.

app.js — the spring
function stepCarousel(dt) {
  const k = 135, c = 2 * Math.sqrt(k) * 0.74;
  P.v += (k * (S.month - P.p) - c * P.v) * dt; P.p += P.v * dt;
  if (Math.abs(S.month - P.p) < 0.0004 && Math.abs(P.v) < 0.003) { P.p = S.month; P.v = 0; }
}

The liquid follows one rule: it behaves like water. It is a single ribbon of even width that runs through the chosen days in date order, with no drips, no drops and no bubbles. At the end of a week it turns in a U into the gap between the rows, runs back along it like water in a channel, and turns again into the next week.

app.js — the water rule
// The liquid in one month pane: ONE continuous ribbon of water of uniform width (Ulvi's water rule, 2026-10-18: no
// drips, no droplets, no necks, no bulbs). It runs from the origin along the selected days in date order; at the end
// of a week it turns in a U into the gutter between the rows, runs back along the gutter like water in a channel and
// turns again into the start of the next week. Arrival s of every point = when the pour reaches it (1 per day, 1.5 for
// the channel); the shader shows s < G. Returns segments [ax, ay, ar, sA, bx, by, br, sB] and the largest arrival.
const RIBBON = () => M.cell * 0.19;
const CHANNEL_T = 1.5;
app.js — the channel between two weeks
// the channel: U-turn into the gutter, back along it, U-turn into the next week
const N = cc(rn, xn), yg = (B[1] + N[1]) / 2, v = Math.sign(N[1] - B[1]), bulge = Math.sign(B[0] - N[0]) || 1, rho = Math.abs(yg - B[1]) / 2;
const pts = [];
for (let j = 0; j <= 7; j++) { const t = j / 7; pts.push([B[0] + bulge * rho * Math.sin(Math.PI * t), (B[1] + yg) / 2 - v * rho * Math.cos(Math.PI * t)]); }
for (let j = 0; j <= 7; j++) { const t = j / 7; pts.push([N[0] - bulge * rho * Math.sin(Math.PI * t), (yg + N[1]) / 2 - v * rho * Math.cos(Math.PI * t)]); }
let L = 0; const acc = [0]; for (let j = 1; j < pts.length; j++) { L += Math.hypot(pts[j][0] - pts[j - 1][0], pts[j][1] - pts[j - 1][1]); acc.push(L); }
for (let j = 0; j < pts.length - 1; j++) {
  if (acc[j + 1] - acc[j] < 1e-3) continue;
  out.push([pts[j][0], pts[j][1], R, k + acc[j] / L * CHANNEL_T, pts[j + 1][0], pts[j + 1][1], R, k + acc[j + 1] / L * CHANNEL_T]);
}
k += CHANNEL_T; max = Math.max(max, k);

Every point along the ribbon has an arrival time: when the pour reaches it. The flow front moves toward its target with a little inertia, so the liquid pours out, runs back when the range shrinks, and stops cleanly at the last day.

app.js — the pour
function stepLiquid(dt) {
  const W = wantedSpec(), C0 = LQ.spec;
  const sgn = (sp) => Math.sign(sp.far - sp.origin);
  if (W.origin !== C0.origin) {                           // a new first pick: pour again, from the tube
    LQ.spec = W; LQ.max = specMax(W); LQ.G = -FEED(); LQ.target = LQ.max;
  } else if (sgn(W) && sgn(C0) && sgn(W) !== sgn(C0) && LQ.G > 0.31) {
    LQ.target = 0.3;                                      // flowing the other way: pull back into the origin first
  } else if (W.far !== C0.far) {
    const mW = specMax(W);
    const grows = (sgn(W) === sgn(C0) || !sgn(C0)) && Math.abs(W.far - W.origin) >= Math.abs(C0.far - C0.origin);
    if (grows || LQ.G <= mW + 0.01) { LQ.spec = W; LQ.max = mW; LQ.target = mW; }
    else LQ.target = mW;                                  // shrinking: let the liquid run back, then drop the cells
  } else LQ.target = LQ.max;
  const diff = LQ.target - LQ.G;
  const v = Math.sign(diff) * Math.min(Math.abs(diff) * 9, Math.max(5, Math.abs(diff) * 3.6), 40);
  LQ.gv += (v - LQ.gv) * Math.min(1, dt * 14);
  LQ.G += LQ.gv * dt;
  if ((diff > 0 && LQ.G > LQ.target) || (diff < 0 && LQ.G < LQ.target) || Math.abs(diff) < 0.002) { LQ.G = LQ.target; LQ.gv = 0; }
}

In the shader the ribbon is a smooth union of round segments, cut off at the flow front. Its distance field is lifted into a height, so the liquid gets real normals for its light.

glass.js — one ribbon
for (int j = 0; j < MAXSEG; j++) {
  if (j >= n) break;
  vec4 A = texelFetch(uSeg, ivec2(2 * j, i), 0);
  if (G <= A.w) continue;
  vec4 B = texelFetch(uSeg, ivec2(2 * j + 1, i), 0);
  float t = clamp((G - A.w) / max(B.w - A.w, 1e-4), 0., 1.);
  vec2 b = mix(A.xy, B.xy, t); float ra = A.z, rb = mix(A.z, B.z, t);
  if (B.w <= 0.0001) { ra *= fr; rb *= fr; }
  vec2 ba = b - A.xy; float bb = dot(ba, ba);
  vec2 pa = q - A.xy; float h = bb > 0.01 ? clamp(dot(pa, ba) / bb, 0., 1.) : 0.;
  float r0 = bb > 0.01 ? mix(ra, rb, h) : rb;
  float d0 = length(pa - ba * h) - r0;
  if (wantS) {
    float wj = exp(-clamp(d0, -Rb, 4. * Rb) / (0.3 * Rb)) * (B.w <= 0.0001 ? 0. : 1.);
    ss += wj * mix(A.w, mix(A.w, B.w, t), h); sw += wj;
  }
  d = smin(d, d0, k);
}

The only motion on its surface is a long, shallow swell that travels with the flow. It changes the height, never the width, so it can never pinch into beads.

glass.js — the swell
// a long, shallow swell travelling with the flow (no beads: the width stays even)
float breathe = sin(s * 1.1 - uTime * 1.3);
d0 -= breathe * 0.35 * uFx.w; D.yz -= breathe * 0.35 * uFx.w;
glass.js — molten amber
vec3 molten(float th, float fl, float s){
  vec3 deep = vec3(0.60, 0.16, 0.05), body = vec3(0.98, 0.48, 0.14), hot = vec3(1.0, 0.88, 0.60);
  vec3 c = mix(deep, body, smoothstep(0.0, 0.55, th));
  c = mix(c, hot, smoothstep(0.42, 1.0, th) * (0.35 + 0.65 * fl));
  return c;
}

The glass is heavy frost of the photo, with a bevel that bends a sharper copy of it and splits it slightly into colours at the rim.

glass.js — the bevel and the frost
// refraction at the bevel (+ chromatic dispersion), frost inside
float bend = x * x * x * 34. * sc;
vec2 o0 = proj(uFwd[i], q - g * bend + rip) - p;
vec2 o1 = o0 * 1.12, o2 = o0 * 1.24;
vec3 frost = photoH(p + o0 * 0.5);
vec3 img = frost;
if (x > 0.02) {
  vec3 rim = vec3(texture(uBgM, uvOf(p + o0)).r, texture(uBgM, uvOf(p + o1)).g, texture(uBgM, uvOf(p + o2)).b);
  img = mix(frost, grade(rim, p), smoothstep(0.05, 0.9, x));
}

The tube beside the front pane is a calm gauge: its level falls when a new pick begins and rises with the range. Its surface sways gently, and the meniscus climbs the glass wall.

glass.js — the level in the tube
float lvl = uTubeL.x;
float surf = (TS.y - wall) - (TS.y - 2. * wall) * lvl + uTubeL.y * (tq.x - tr) + uTubeL.z * sin(tq.x * 0.32 / sc + uTime * 2.8);
surf -= 2.6 * sc * pow(abs(xn), 4.);                                       // meniscus climbs the wall
Stage 3 · the glass in WebGLOpen ↗
Three glass months in an arc over the sunset, September, October and November, with October in front. An amber ribbon runs through the 14th to the 21st between violet end tiles, fed from a full glass tube on the right.

4 · The finish

A new first pick no longer replaces the liquid at once. The old ribbon drains back into the tube first, and the new one pours from the new date.

app.js — drain back, then pour
if (W.origin !== C0.origin) {                           // drain back into the tube, then pour from the new origin
  LQ.target = -FEED();
  if (LQ.G <= -FEED() + 0.02 || reduced) { LQ.spec = W; LQ.max = specMax(W); LQ.G = reduced ? LQ.max : -FEED(); LQ.target = LQ.max; }
}

On a touch screen, or with the mouse on the glass away from the days, a horizontal drag scrubs through the months. On release, the drag's speed carries it on to the nearest month.

app.js — the drag
function dragMove(e) {
  const D = P.drag; const dx = e.clientX - D.x0;
  if (!D.live && Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(e.clientY - UI.down.y)) { D.live = true; stage.setPointerCapture?.(D.id); UI.down.moved = true; root.classList.add('dragging'); }
  if (!D.live) return;
  const now = performance.now(), span = M.w * 0.85;
  D.v = 0.8 * D.v + 0.2 * (-(e.clientX - D.last) / span) / Math.max((now - D.t) / 1000, 1e-3);
  D.last = e.clientX; D.t = now;
  P.p = D.p0 - dx / span; P.v = 0;
  ensurePanes(); kick();
}
app.js — the release
if (P.drag) {
  const dg = P.drag; P.drag = null; root.classList.remove('dragging');
  if (dg.live) {
    const proj = P.p + clamp(dg.v, -6, 6) * 0.18;
    const to = clamp(Math.round(proj), S.month - 3, S.month + 3);
    P.v = dg.v; if (to !== S.month) goMonth(to - S.month); else kick();
    return;
  }
}

A sideways swipe on a trackpad turns one month per swipe.

app.js — the trackpad
stage.addEventListener('wheel', (e) => {
  if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) * 1.2) return;
  e.preventDefault();
  const now = performance.now();
  if (now - UI.wheelT > 260) UI.wheel = 0;
  UI.wheelT = now; UI.wheel += e.deltaX;
  if (Math.abs(UI.wheel) > 70 && !UI.wheelLock) { goMonth(Math.sign(UI.wheel)); UI.wheel = 0; UI.wheelLock = true; setTimeout(() => { UI.wheelLock = false; }, 420); }
}, { passive: false });

With reduced motion, the months swap without swinging, the liquid appears in place, the light sweep stops and the loop goes to sleep.

app.js — reduced motion
const mq = matchMedia('(prefers-reduced-motion: reduce)');
let reduced = mq.matches || qs.has('reduced');
mq.addEventListener?.('change', (e) => { reduced = e.matches || qs.has('reduced'); root.classList.toggle('reduced', reduced); kick(); });
root.classList.toggle('reduced', reduced);

When nothing moves, only the slow light changes, so it is drawn every other frame. While the panes swing or the liquid runs, the glass renders at a lower resolution; the motion hides it.

app.js — the idle saving
// at rest only the slow idle light moves: draw it at half rate and give the GPU back every other frame
const moving = P.p !== S.month || LQ.G !== LQ.target || UI.rip || P.drag || Math.abs(TUBE.lv) > 0.002 || Math.abs(TUBE.tv) > 0.01 || now - UI.lastInput < 400;
frameN++;
if (moving || frameN % 2 === 0 || reduced) layoutAndRender(now);
app.js — dynamic resolution
// dynamic resolution: the swing renders at 0.62, a moving liquid at 0.8 (both hide it); everything at rest is full
F.scale = (Math.abs(P.p - S.month) > 0.03 || P.drag?.live) ? 0.62 : (Math.abs(LQ.gv) > 0.05 || UI.rip) && !reduced ? 0.8 : 1;
if (F.scale < 1 && now - UI.stress < 600) F.scale *= 0.8;          // a late frame while moving: one step lower for a moment

Last, nothing is stored, and any key an older build left behind is wiped, so every visit opens on the showcase.

app.js — a clean start
// storage hygiene: nothing is persisted (the page always opens on the showcase); drop any older dv64 keys
try { for (const k of Object.keys(localStorage)) if (k.startsWith('dv64.') && !k.startsWith('dv64.v1.')) localStorage.removeItem(k); } catch (e) { /* private mode */ }
Stage 4 · the finished pickerOpen ↗
The finished picker after choosing Oct 8 – 24: the amber ribbon runs along three weeks, turning in a U between the rows, from a violet 8th to a violet 24th, beside the glass tube.
Stage 4 · on a phoneOpen ↗
The picker on a phone: one full-width glass month, October 2026, the amber ribbon winding from the violet 14th to the violet 21st, the readout below.

Make it yours

  • Your backdrop is the photo in img/; manifest.js lists its sizes and its credit. The grade in glass.js pulls it toward the violet sky and the plum floor.
  • Your colours are a few lines: molten() for the liquid, endTile() for the violet ends, and the html:not(.gl) rules for the CSS fallback.
  • Your form: the range is in <output name="range"> and in the rangechange event, as ISO dates.
  • Accessibility is already there: an ARIA grid with a roving tabindex and the full keyboard pattern, full-date labels, aria-selected and aria-current, a live region, 44 px targets on a phone, a visible focus ring, prefers-reduced-motion honoured, and a CSS glass fallback without WebGL 2.

The code is on GitHub at UlviShabanbayli/domvybe-glass-datepicker, under the MIT licence: use it in your own projects, commercial ones included. The sunset photo is AI-generated (Google Gemini); the glass and the liquid are made in code.

The one idea worth keeping

Give the DOM and the GPU the same matrix. Each month is a real, accessible grid in the DOM; the shader only paints glass and light under it, through the very matrix the pane wears in CSS, so the two never come apart, whatever the arc does.