Phase
import { useId, useState, type CSSProperties } from 'react';
const MONTH = 29.53; // days from new moon to new moon
const R = 42;
const C = { x: 115, y: 60 };
// The lit part of the disc for a day of the month: the limb's half circle plus the
// terminator, an ellipse whose width is the cosine of the phase angle. Waxing, the
// right side is lit; a crescent's terminator bows toward the lit side, a gibbous
// moon's away from it.
function litPath(day: number, r = R, cx = C.x, cy = C.y) {
const a = (2 * Math.PI * day) / MONTH;
const waxing = a <= Math.PI;
const rx = Math.abs(Math.cos(a)) * r;
const crescent = Math.cos(a) > 0;
const limb = waxing ? 1 : 0;
const sweep = waxing ? (crescent ? 0 : 1) : crescent ? 1 : 0;
return `M${cx} ${cy - r} A${r} ${r} 0 0 ${limb} ${cx} ${cy + r} A${rx.toFixed(2)} ${r} 0 0 ${sweep} ${cx} ${cy - r}Z`;
}
const lit = (day: number) => Math.round(((1 - Math.cos((2 * Math.PI * day) / MONTH)) / 2) * 100);
function name(day: number) {
if (day < 1 || day > 28.5) return 'New moon';
if (day < 6.4) return 'Waxing crescent';
if (day < 8.4) return 'First quarter';
if (day < 13.8) return 'Waxing gibbous';
if (day < 15.8) return 'Full moon';
if (day < 21.1) return 'Waning gibbous';
if (day < 23.1) return 'Last quarter';
return 'Waning crescent';
}
const CRATERS = [
[-14, -16, 7],
[12, -6, 9],
[-8, 12, 5],
[18, 18, 6],
[-22, 4, 4],
[2, 26, 4],
];
const STARS = [
[18, 20],
[44, 92],
[62, 14],
[180, 22],
[204, 70],
[190, 104],
[28, 64],
[168, 50],
];
const css = `
.slider-phase { --sky: light-dark(#42454c, #1a1c1f); --moon: light-dark(#fbfcfd, #e5e6e9); display: grid; gap: 10px; width: 230px; }
.slider-phase__sky { display: block; width: 230px; height: 122px; border-radius: 12px; background: radial-gradient(circle at 50% 50%, color-mix(in oklab, var(--moon) 8%, var(--sky)), var(--sky) 60%); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.slider-phase__star { fill: var(--moon); animation: slider-phase-twinkle 2.6s ease-in-out infinite alternate; }
/* The night side keeps a faint earthshine. */
.slider-phase__disc { fill: color-mix(in oklab, var(--moon) 13%, var(--sky)); }
.slider-phase__lit { fill: var(--moon); }
.slider-phase__crater { fill: color-mix(in oklab, var(--moon) 84%, light-dark(#6d7179, #7d8087)); }
.slider-phase__halo { fill: none; stroke: color-mix(in oklab, var(--moon) 10%, transparent); stroke-width: 8; }
.slider-phase__read { display: flex; align-items: baseline; justify-content: space-between; }
.slider-phase__name { color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; }
.slider-phase__lit-pc { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
/* The track is a row of little moons. */
.slider-phase__track { position: relative; height: 34px; }
.slider-phase__moons { position: absolute; left: 0; right: 0; top: 0; display: flex; justify-content: space-between; padding: 0 3px; }
.slider-phase__mini { width: 14px; height: 14px; }
.slider-phase__mini circle { fill: light-dark(#dbdde1, #2e3035); }
.slider-phase__mini path { fill: light-dark(#5f626a, #a7abb2); }
.slider-phase__rail { position: absolute; left: 0; right: 0; top: 23px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
.slider-phase__thumb { position: absolute; top: 17px; left: calc(var(--slider-phase-v) * (100% - 16px)); width: 16px; height: 16px; box-sizing: border-box; border: 3px solid light-dark(#373a40, #f1f2f3); border-radius: 50%; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
.slider-phase:has(.slider-phase__input:focus-visible) .slider-phase__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-phase__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-phase__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 34px; }
.slider-phase__input::-moz-range-thumb { width: 16px; height: 34px; border: 0; }
@keyframes slider-phase-twinkle { from { opacity: 0.25; } to { opacity: 0.9; } }
@media (prefers-reduced-motion: reduce) {
.slider-phase__star { animation: none; opacity: 0.6; }
}
`;
export function Preview() {
const [day, setDay] = useState(10);
const clip = useId();
return (
<>
<style href="kit-sliders-phase" precedence="kit">
{css}
</style>
<div className="slider-phase" style={{ '--slider-phase-v': (day / 29).toFixed(4) } as CSSProperties}>
<svg className="slider-phase__sky" viewBox="0 0 230 122" aria-hidden="true">
<defs>
<clipPath id={clip}>
<path className="slider-phase__shape" d={litPath(day)} />
</clipPath>
</defs>
{STARS.map(([x, y], i) => (
<circle key={i} className="slider-phase__star" cx={x} cy={y} r={i % 3 ? 0.9 : 1.3} style={{ animationDelay: `${-i * 0.7}s` }} />
))}
<circle className="slider-phase__halo" cx={C.x} cy={C.y} r={R + 4} />
<circle className="slider-phase__disc" cx={C.x} cy={C.y} r={R} />
<path className="slider-phase__lit slider-phase__shape" d={litPath(day)} />
<g clipPath={`url(#${clip})`}>
{CRATERS.map(([dx, dy, r]) => (
<circle key={`${dx}-${dy}`} className="slider-phase__crater" cx={C.x + dx} cy={C.y + dy} r={r} />
))}
</g>
</svg>
<div className="slider-phase__read" aria-hidden="true">
<span className="slider-phase__name">{name(day)}</span>
<span className="slider-phase__lit-pc">
Day {day} · {lit(day)}% lit
</span>
</div>
<div className="slider-phase__track">
<span className="slider-phase__moons" aria-hidden="true">
{Array.from({ length: 8 }, (_, i) => (
<svg key={i} className="slider-phase__mini" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6" />
<path d={litPath((i * MONTH) / 8 + 0.01, 6, 7, 7)} />
</svg>
))}
</span>
<span className="slider-phase__rail" aria-hidden="true" />
<span className="slider-phase__thumb" aria-hidden="true" />
<input className="slider-phase__input" type="range" min={0} max={29} value={day} onChange={(e) => setDay(+e.target.value)} aria-label="Day of the lunar month" aria-valuetext={`Day ${day}, ${name(day).toLowerCase()}, ${lit(day)} percent lit`} />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderPhase(el) on the mounted element instead.
<div class="slider-phase" style="--slider-phase-v:0.3448">
<svg class="slider-phase__sky" viewBox="0 0 230 122" aria-hidden="true">
<defs>
<clipPath id="slider-phase">
<path class="slider-phase__shape" d="M115 18 A42 42 0 0 1 115 102 A22.20 42 0 0 1 115 18Z"></path>
</clipPath>
</defs>
<circle class="slider-phase__star" cx="18" cy="20" r="1.3" style="animation-delay:0s"></circle>
<circle class="slider-phase__star" cx="44" cy="92" r="0.9" style="animation-delay:-0.7s"></circle>
<circle class="slider-phase__star" cx="62" cy="14" r="0.9" style="animation-delay:-1.4s"></circle>
<circle class="slider-phase__star" cx="180" cy="22" r="1.3" style="animation-delay:-2.0999999999999996s"></circle>
<circle class="slider-phase__star" cx="204" cy="70" r="0.9" style="animation-delay:-2.8s"></circle>
<circle class="slider-phase__star" cx="190" cy="104" r="0.9" style="animation-delay:-3.5s"></circle>
<circle class="slider-phase__star" cx="28" cy="64" r="1.3" style="animation-delay:-4.199999999999999s"></circle>
<circle class="slider-phase__star" cx="168" cy="50" r="0.9" style="animation-delay:-4.8999999999999995s"></circle>
<circle class="slider-phase__halo" cx="115" cy="60" r="46"></circle>
<circle class="slider-phase__disc" cx="115" cy="60" r="42"></circle>
<path class="slider-phase__lit slider-phase__shape" d="M115 18 A42 42 0 0 1 115 102 A22.20 42 0 0 1 115 18Z"></path>
<g clip-path="url(#slider-phase)">
<circle class="slider-phase__crater" cx="101" cy="44" r="7"></circle>
<circle class="slider-phase__crater" cx="127" cy="54" r="9"></circle>
<circle class="slider-phase__crater" cx="107" cy="72" r="5"></circle>
<circle class="slider-phase__crater" cx="133" cy="78" r="6"></circle>
<circle class="slider-phase__crater" cx="93" cy="64" r="4"></circle>
<circle class="slider-phase__crater" cx="117" cy="86" r="4"></circle>
</g>
</svg>
<div class="slider-phase__read" aria-hidden="true">
<span class="slider-phase__name">Waxing gibbous</span>
<span class="slider-phase__lit-pc">Day 10 · 76% lit</span>
</div>
<div class="slider-phase__track">
<span class="slider-phase__moons" aria-hidden="true">
<svg class="slider-phase__mini" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6"></circle>
<path d="M7 1 A6 6 0 0 1 7 13 A6.00 6 0 0 0 7 1Z"></path>
</svg>
<svg class="slider-phase__mini" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6"></circle>
<path d="M7 1 A6 6 0 0 1 7 13 A4.23 6 0 0 0 7 1Z"></path>
</svg>
<svg class="slider-phase__mini" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6"></circle>
<path d="M7 1 A6 6 0 0 1 7 13 A0.01 6 0 0 1 7 1Z"></path>
</svg>
<svg class="slider-phase__mini" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6"></circle>
<path d="M7 1 A6 6 0 0 1 7 13 A4.25 6 0 0 1 7 1Z"></path>
</svg>
<svg class="slider-phase__mini" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6"></circle>
<path d="M7 1 A6 6 0 0 0 7 13 A6.00 6 0 0 0 7 1Z"></path>
</svg>
<svg class="slider-phase__mini" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6"></circle>
<path d="M7 1 A6 6 0 0 0 7 13 A4.23 6 0 0 0 7 1Z"></path>
</svg>
<svg class="slider-phase__mini" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6"></circle>
<path d="M7 1 A6 6 0 0 0 7 13 A0.01 6 0 0 1 7 1Z"></path>
</svg>
<svg class="slider-phase__mini" viewBox="0 0 14 14">
<circle cx="7" cy="7" r="6"></circle>
<path d="M7 1 A6 6 0 0 0 7 13 A4.25 6 0 0 1 7 1Z"></path>
</svg>
</span>
<span class="slider-phase__rail" aria-hidden="true"></span>
<span class="slider-phase__thumb" aria-hidden="true"></span>
<input class="slider-phase__input" type="range" min="0" max="29" aria-label="Day of the lunar month" aria-valuetext="Day 10, waxing gibbous, 76 percent lit" value="10"/>
</div>
</div>.slider-phase { --sky: light-dark(#42454c, #1a1c1f); --moon: light-dark(#fbfcfd, #e5e6e9); display: grid; gap: 10px; width: 230px; }
.slider-phase__sky { display: block; width: 230px; height: 122px; border-radius: 12px; background: radial-gradient(circle at 50% 50%, color-mix(in oklab, var(--moon) 8%, var(--sky)), var(--sky) 60%); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.slider-phase__star { fill: var(--moon); animation: slider-phase-twinkle 2.6s ease-in-out infinite alternate; }
/* The night side keeps a faint earthshine. */
.slider-phase__disc { fill: color-mix(in oklab, var(--moon) 13%, var(--sky)); }
.slider-phase__lit { fill: var(--moon); }
.slider-phase__crater { fill: color-mix(in oklab, var(--moon) 84%, light-dark(#6d7179, #7d8087)); }
.slider-phase__halo { fill: none; stroke: color-mix(in oklab, var(--moon) 10%, transparent); stroke-width: 8; }
.slider-phase__read { display: flex; align-items: baseline; justify-content: space-between; }
.slider-phase__name { color: light-dark(#373a40, #f1f2f3); font-size: 14px; font-weight: 600; }
.slider-phase__lit-pc { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
/* The track is a row of little moons. */
.slider-phase__track { position: relative; height: 34px; }
.slider-phase__moons { position: absolute; left: 0; right: 0; top: 0; display: flex; justify-content: space-between; padding: 0 3px; }
.slider-phase__mini { width: 14px; height: 14px; }
.slider-phase__mini circle { fill: light-dark(#dbdde1, #2e3035); }
.slider-phase__mini path { fill: light-dark(#5f626a, #a7abb2); }
.slider-phase__rail { position: absolute; left: 0; right: 0; top: 23px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
.slider-phase__thumb { position: absolute; top: 17px; left: calc(var(--slider-phase-v) * (100% - 16px)); width: 16px; height: 16px; box-sizing: border-box; border: 3px solid light-dark(#373a40, #f1f2f3); border-radius: 50%; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
.slider-phase:has(.slider-phase__input:focus-visible) .slider-phase__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-phase__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-phase__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 34px; }
.slider-phase__input::-moz-range-thumb { width: 16px; height: 34px; border: 0; }
@keyframes slider-phase-twinkle { from { opacity: 0.25; } to { opacity: 0.9; } }
@media (prefers-reduced-motion: reduce) {
.slider-phase__star { animation: none; opacity: 0.6; }
}function initSliderPhase(root) {
var input = root.querySelector('.slider-phase__input');
var shapes = root.querySelectorAll('.slider-phase__sky .slider-phase__shape');
var name = root.querySelector('.slider-phase__name');
var lit = root.querySelector('.slider-phase__lit-pc');
var month = 29.53;
// The lit part of the disc: the limb's half circle plus the terminator, an
// ellipse whose width is the cosine of the phase angle.
function litPath(day) {
var r = 42;
var cx = 115;
var cy = 60;
var a = (2 * Math.PI * day) / month;
var waxing = a <= Math.PI;
var crescent = Math.cos(a) > 0;
var sweep = waxing ? (crescent ? 0 : 1) : crescent ? 1 : 0;
return 'M' + cx + ' ' + (cy - r) + ' A' + r + ' ' + r + ' 0 0 ' + (waxing ? 1 : 0) + ' ' + cx + ' ' + (cy + r) + ' A' + (Math.abs(Math.cos(a)) * r).toFixed(2) + ' ' + r + ' 0 0 ' + sweep + ' ' + cx + ' ' + (cy - r) + 'Z';
}
function phaseName(day) {
if (day < 1 || day > 28.5) return 'New moon';
if (day < 6.4) return 'Waxing crescent';
if (day < 8.4) return 'First quarter';
if (day < 13.8) return 'Waxing gibbous';
if (day < 15.8) return 'Full moon';
if (day < 21.1) return 'Waning gibbous';
if (day < 23.1) return 'Last quarter';
return 'Waning crescent';
}
function update() {
var day = +input.value;
var pc = Math.round(((1 - Math.cos((2 * Math.PI * day) / month)) / 2) * 100);
shapes.forEach(function (path) { path.setAttribute('d', litPath(day)); });
root.style.setProperty('--slider-phase-v', (day / +input.max).toFixed(4));
name.textContent = phaseName(day);
lit.textContent = 'Day ' + day + ' · ' + pc + '% lit';
input.setAttribute('aria-valuetext', 'Day ' + day + ', ' + phaseName(day).toLowerCase() + ', ' + pc + ' percent lit');
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-phase').forEach(initSliderPhase);Goal: Create a "Phase" slider.
Visual behavior: Pick a day of the lunar month and the moon shows its phase with a true curved terminator, over a track of little moons.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- The lit part is the limb’s half circle plus the terminator, an ellipse whose width is the cosine of the phase angle; it bows toward the lit side for a crescent and away for a gibbous moon.
- The craters are clipped to the lit shape, and the night side keeps a faint earthshine.
- The track carries eight little moons drawn with the same function, and the reading gives the phase name and how much is lit.
Accessibility: A native range input named "Day of the lunar month" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the stars stop twinkling.
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 "Phase" slider.
Visual behavior: Pick a day of the lunar month and the moon shows its phase with a true curved terminator, over a track of little moons.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- The lit part is the limb’s half circle plus the terminator, an ellipse whose width is the cosine of the phase angle; it bows toward the lit side for a crescent and away for a gibbous moon.
- The craters are clipped to the lit shape, and the night side keeps a faint earthshine.
- The track carries eight little moons drawn with the same function, and the reading gives the phase name and how much is lit.
Accessibility: A native range input named "Day of the lunar month" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the stars stop twinkling.
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
Pick a day of the lunar month and the moon shows its phase with a true curved terminator, over a track of little moons.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, morph
- Techniques
- svg, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input named "Day of the lunar month" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the stars stop twinkling.