Phase

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/slider-phase
Source on GitHub

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.

More sliders

See all
99.1MHzClassic
Radio
Scissor Lift
Swing Ride
Tyre