Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 8 min read

Computing the Moon phase for any birthday in plain JavaScript

We recently shipped a small free tool that answers one question: which Moon phase were you born under? You pick a date, and it shows the phase, how much of the disc was lit, the exact time of the nearest new or full moon

We recently shipped a small free tool that answers one question: which Moon phase were you born under? You pick a date, and it shows the phase, how much of the disc was lit, the exact time of the nearest new or full moon, and which sign the Moon was in.

The quick way to build this is the mean-cycle trick: take the days since a known new moon, divide by 29.53 and look at the remainder. It is close, but a real lunation is not 29.53 days long. Between 1950 and 2025 the actual length ranged from 29.28 to 29.83 days, and a mean cycle anchored on the new moon of January 2025 missed the real new moons by up to a full day. For a tool that names a calendar day, a day of error is the whole answer.

So we computed the real sky instead. Everything below runs in the browser, has no dependencies, and fits in about 100 lines.

A phase is just an angle

From Earth, the Moon's phase depends on one number: how far the Moon is from the Sun along the ecliptic. Astronomers call it the elongation.

Elongation Phase
0ยฐ New Moon
90ยฐ First Quarter
180ยฐ Full Moon
270ยฐ Last Quarter

The lit fraction of the disc follows from the same angle:

const RAD = Math.PI / 180;
const norm = x => ((x % 360) + 360) % 360;

// 0 = new, 90 = first quarter, 180 = full, 270 = last quarter
function elongation(jd) { return norm(moonLongitude(jd) - sunLongitude(jd)); }
function illumination(e) { return (1 - Math.cos(e * RAD)) / 2; }

The illumination formula treats the Sun as infinitely far away. The real phase angle differs by at most about 0.15ยฐ, which is invisible in a lit fraction rounded to a whole percent.

So the job is two functions: the Sun's ecliptic longitude and the Moon's, both for a Julian Day.

const jdFromMs = ms => ms / 86400000 + 2440587.5;

The Sun: Meeus, chapter 25

Jean Meeus' Astronomical Algorithms (2nd ed., 1998) is still the standard reference for this. Chapter 25 gives a low-precision solar position that is good to about 0.01ยฐ:

function sunLongitude(jd) {
  const T = (jd - 2451545) / 36525;                       // centuries since J2000.0
  const L0 = 280.46646 + 36000.76983 * T + 0.0003032 * T * T;
  const M = (357.52911 + 35999.05029 * T - 0.0001537 * T * T) * RAD;
  const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(M)
          + (0.019993 - 0.000101 * T) * Math.sin(2 * M)
          + 0.000289 * Math.sin(3 * M);                    // equation of center
  return norm(L0 + C - 0.00569);                           // minus aberration
}

The Moon: 40 terms of a 60-term series

The Moon is the hard part. Its orbit is pulled around by the Sun, so its longitude is a mean motion plus a long sum of periodic terms. Meeus' table 47.A lists 60 of them for longitude. Each term combines four fundamental arguments: the Moon's mean elongation D, the Sun's mean anomaly M, the Moon's mean anomaly M' and its argument of latitude F.

We kept the 40 largest terms plus the three additive corrections Meeus lists separately:

// [D, M, M', F, coefficient in 1e-6 degrees] โ€” the largest terms of Meeus table 47.A
const MOON_TERMS = [
  [0, 0, 1, 0, 6288774], [2, 0, -1, 0, 1274027], [2, 0, 0, 0, 658314], [0, 0, 2, 0, 213618],
  [0, 1, 0, 0, -185116], [0, 0, 0, 2, -114332], [2, 0, -2, 0, 58793], [2, -1, -1, 0, 57066],
  // ... 32 more rows
];

function moonLongitude(jd) {
  const T = (jd - 2451545) / 36525;
  const Lp = 218.3164477 + 481267.88123421 * T - 0.0015786 * T * T;          // mean longitude
  const D  = (297.8501921 + 445267.1114034 * T - 0.0018819 * T * T) * RAD;
  const M  = (357.5291092 + 35999.0502909 * T - 0.0001536 * T * T) * RAD;
  const Mp = (134.9633964 + 477198.8675055 * T + 0.0087414 * T * T) * RAD;
  const F  = (93.2720950 + 483202.0175233 * T - 0.0036539 * T * T) * RAD;
  const E  = 1 - 0.002516 * T - 0.0000074 * T * T;     // Earth's orbit is slowly getting rounder

  let sum = 0;
  for (const [d, m, mp, f, c0] of MOON_TERMS) {
    const c = Math.abs(m) === 1 ? c0 * E : Math.abs(m) === 2 ? c0 * E * E : c0;
    sum += c * Math.sin(d * D + m * M + mp * Mp + f * F);
  }
  const A1 = (119.75 + 131.849 * T) * RAD, A2 = (53.09 + 479264.290 * T) * RAD;
  sum += 3958 * Math.sin(A1) + 1962 * Math.sin(Lp * RAD - F) + 318 * Math.sin(A2);
  return norm(Lp + sum / 1e6);
}

How much does dropping 20 terms cost? We compared it with the full series (as implemented in the astronomia package) every 6 hours from 2020 to 2029, 14,612 samples:

  • mean difference 0.0013ยฐ
  • worst case 0.0052ยฐ

The Moon moves about 0.55ยฐ per hour, so 0.005ยฐ is roughly 30 seconds of its motion.

Finding the exact moment

A new or full moon is an instant: the moment the elongation crosses 0ยฐ or 180ยฐ. To find it, we start near the expected time and take Newton steps, using the Moon's mean angular speed relative to the Sun (about 12.19ยฐ per day) as the derivative:

const SYNODIC = 29.530588853;
const DEG_PER_DAY = 360 / SYNODIC;

function solve(jd, target) {
  for (let i = 0; i < 8; i++) {
    const diff = norm(target - elongation(jd) + 180) - 180;   // signed, -180..180
    jd += diff / DEG_PER_DAY;
    if (Math.abs(diff) < 1e-5) break;
  }
  return jd;
}

It converges in three or four iterations. Meeus also has a dedicated algorithm for phase instants (chapter 49), but root-finding on the elongation reuses the two functions we already have and works for any target angle.

Naming a calendar day is the tricky part

The astronomy is the easy part. Turning an instant into the answer for a date is where the edge cases live.

A principal phase belongs to the day that contains it. That is how printed calendars do it. Every other day is named by the quadrant the Moon is in at local noon: waxing crescent, waxing gibbous, waning gibbous or waning crescent.

function dayPhase(y, m, d) {
  const start = jdFromMs(new Date(y, m - 1, d).getTime());
  const end   = jdFromMs(new Date(y, m - 1, d + 1).getTime());   // 23 or 25 hours on DST days
  const noon  = jdFromMs(new Date(y, m - 1, d, 12).getTime());
  const e0 = elongation(start);
  const e1 = e0 + norm(elongation(end) - e0);                     // unwrapped across 360ยฐ
  for (let k = 0; k <= 4; k++) {
    const target = k * 90;
    if (target > e0 && target <= e1) {
      return { exact: solve(noon, target % 360), quarter: k % 4 };   // this day has a principal phase
    }
  }
  return { quarterAtNoon: Math.floor(elongation(noon) / 90) };     // an in-between day
}

A few things we learned:

  • The day is the visitor's own day. Building dates with new Date(y, m - 1, d) keeps everything in the browser's time zone, and asking for d + 1 gives the right day length across daylight-saving changes. new Date("2024-04-23") would give UTC midnight and quietly shift the result for half the world.
  • Close calls get a warning. If the exact phase falls within 6 hours of midnight, someone a few time zones away has it on a different date. The tool tells you instead of pretending the answer is universal.
  • A new moon day opens the new cycle. Our first version showed "day 29.5 of 29.5" for a date whose new moon came in the afternoon, because the code still counted from the previous new moon. If a day contains the new moon, the cycle starts there.
  • The Moon can change signs during your birthday. It does roughly every 2.3 days. When the sign at midnight differs from the sign at the next midnight, 30 rounds of bisection find the moment of the change to well under a second.

Checking it against the US Naval Observatory

The USNO publishes the times of every principal phase through its public API. We ran the engine against all 149 phases from 2024 to 2026:

  • 149 of 149 fall on the same UTC day
  • median difference 1.7 minutes, worst 2.8 minutes

The errors were not random: our times were consistently about 1.7 minutes late. Most of that is ฮ”T. Meeus' formulas expect Terrestrial Time, a uniform time scale, and we were feeding them UTC from Date.now(). The two currently differ by about 69 seconds. Adding ฮ”T drops the median to 0.55 minutes and the worst case to 1.7 minutes. The rest comes from the truncated series and from USNO rounding its times to the minute.

We have not shipped the ฮ”T correction yet. A tool that names calendar days only cares about the minute around midnight, and those cases already get the close-call note. It is still on the list, because it is a one-line fix.

One page, two Moon models

There is a second calculation on the result page, and it deliberately uses the simple mean-cycle model we argued against at the top.

The tool belongs to Aetris, a pet astrology app. The app gives every user a "Star Beast" based on their sign and the quarter of the Moon cycle they were born in, and it computes that quarter with a mean cycle counted from a fixed reference new moon. When the website shows your Star Beast, it has to show the same one the app will show you after you install it. So for that part, the page copies the app's logic exactly, simplification included.

We measured the gap: on 6.3% of days from 1950 to 2025, the mean-cycle quarter differs from the one the real sky gives. The rate is about the same in every decade, so it is not drift. It comes from the varying length of real lunations. The page says plainly that the app counts four quarters while the calculator shows eight phases.

Our takeaway: when a website previews a product feature, copy the product's logic, even when you know a more precise one. A preview that disagrees with the product reads as a bug, however accurate it is.

Drawing the Moon

The disc is one photo texture with a computed shadow on top. The shadow is a closed SVG path: half of the limb on the dark side, plus the terminator, which is half of an ellipse whose horizontal radius is rยท|cos e|:

// Dark part of the disc for elongation e, as seen from the northern hemisphere
// (a waxing Moon is lit on the right; south of the equator it is upside down).
function shadowPath(e, cx, cy, r) {
  e = norm(e);
  const rx = Math.abs(r * Math.cos(e * RAD)).toFixed(3);
  const top = `${cx},${cy - r}`, bot = `${cx},${cy + r}`;
  const waxing = e < 180;
  const limb = waxing ? 0 : 1;
  const term = waxing ? (e < 90 ? 0 : 1) : (e > 270 ? 1 : 0);
  return `M${top} A${r},${r} 0 0 ${limb} ${bot} A${rx},${r} 0 0 ${term} ${top} Z`;
}

Two copies of that path with different blur give a sharp edge and a soft glow, so the terminator looks like a terminator and not like a cut-out. Animating e from 0 to the birth elongation gives the little "phase rolling in" effect on the result.

Try it

The calculator is free, needs no sign-up, and everything runs client-side: What moon phase were you born under?

If you want to build your own, the two chapters of Meeus and a list of known phase times to test against will get you very far. Test against an independent source early. Our ฮ”T offset only showed up once we compared with USNO.

Written by Aetris. Questions about the math are welcome in the comments.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.