DEV Community

Cover image for Use your i-moon-gination: Let's build a Moon phase visualizer with CSS and JS! 🗓️🌙
Pascal Thormeier
Pascal Thormeier

Posted on

Use your i-moon-gination: Let's build a Moon phase visualizer with CSS and JS! 🗓️🌙

Cover photo by Flickr user Brendan Keene

Ah, it is spring time on the northern hemisphere! The nights are getting warmer (and shorter!) again, no clouds in sight, the perfect time to go moon-gazing, isn't it? I always had a huge fascination for our largest natural satellite and for the night sky in general, actually.

Let's dig a bit deeper into moon phases today and build our very own moon phase calculator and visualizer!

How do moon phases even work?

I'm by no means an expert of orbital mechanics, let alone most of the maths that are necessary to do orbital mechanics, but I'll try to explain it anyways. The fact that I even know what orbital mechanics is still baffles me.

As you might know, the moon revolves around the Earth and the Earth revolves around the sun. [citation needed]

The Earth evolves around the sun roughly every 12 months, give or take a few minutes, that's what leap years are for. The moon takes roughly 27.3 days to revolve around earth once. At some point in the past, Earth's gravity slowed the rotation of the moon down to the point where the moon's orbit around Earth matched it's own rotation.The moon became tidally locked. This means that it's always facing the same side to Earth.

That doesn't mean that the moon is stationary, though. It does revolve around Earth and the Earth still revolves around the sun. In rare occasions, Earth, the sun and the moon are aligned in a straight line: That's when a solar eclipse (moon between Earth and sun) or a lunar eclipse (Earth between sun and moon) happens.

If that doesn't happen (so, most of the time, really), the moon is illuminated by the sun. As the moon/Earth angle changes, different sides of the moon are illuminated. This results in the different phases of the moon.

Wikipedia user Andonee illustrated this in an awesome way:

Moon phases illustrated

You can see pretty clearly how it works: The moon is always somehow illuminated, but the angle, of which side is illuminated, changes, resulting in the moon phases we see. Each cycle takes roughly 29.5 days. So, 29.5 days = 360 degrees rotation. 29.5 is not 27.3 and that's exactly the point where the maths gets complex. Got it. Let's code.

Boiler plating!

It would be awesome to have a date selector to check different dates. The currently selected date should be shown. And we need - well - a moon. The moon has a light and a dark hemisphere and a divider. Let's start with the HTML first:

<!DOCTYPE html>
  <link rel="stylesheet" href="styles.css">

<h1 id="date-title">
  <!-- Will show the selected date -->

<!-- The moon -->
<div class="sphere">
  <div class="light hemisphere"></div>
  <div class="dark hemisphere"></div>
  <div class="divider"></div>

<!-- The date input -->
<input type="date">

<script src="app.js"></script>
Enter fullscreen mode Exit fullscreen mode

I also added an empty JS file and an empty CSS file, too.

Let's get to styling.

Making it pretty

We start out with the background. We use flexbox to center everything. The title should have a nice, bright color, so it's visible on the dark-blue background.

html {
    background-color: rgba(11,14,58,1);
    overflow: hidden;

body {
    text-align: center;
    width: 100vw;
    height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;

h1 {
    color: #F4F6F0;
    margin-bottom: 50px;
Enter fullscreen mode Exit fullscreen mode

Next, we make the moon (Attention, bad pun ahead!) go round:

.sphere {
    border-radius: 100%;
    width: 300px;
    height: 300px;
    overflow: hidden;
    display: flex;
    align-items: center;
    position: relative;
    margin-bottom: 50px;
Enter fullscreen mode Exit fullscreen mode

You might've noticed that we use flexbox here as well. We need the two hemispheres to be next to each other for the divider to work.

.hemisphere {
    width: 50%;
    height: 100%;

.light {
    background-color: #F4F6F0;

.dark {
    background-color: #575851;
Enter fullscreen mode Exit fullscreen mode

Lastly we need the divider. To simulate an actual sphere, we will style the divider as a circle and rotate it in 3D space. Since the moon rotates around 360 degrees, the divider shoudl also be able to rotate around 360 degrees. The divider therefore needs two sides: A light side and a dark side. We'll use the divider's :after pseudo element for this and rotate it by 180 degrees on the Y axis to serve as the divider's back face:

.divider:after {
    top: 0;
    left: 0;
    width: 300px;
    height: 300px;
    position: absolute;
    border-radius: 100%;
    transform-style: preserve-3d;
    backface-visibility: hidden;

.divider {
    background-color: #575851; /* Dark */

.divider:after {
    content: '';
    background-color: #F4F6F0; /* Light */
    transform: rotateY(180deg);
Enter fullscreen mode Exit fullscreen mode

Making it show the moon phase

To know how far in the phase the moon currently is, we need to know some point in the past of a new moon, so a completely dark one. One such occasion was the 2nd of March 2022 at 6:34pm UTC+1.

A moon phase takes roughly 29.5 days and we need to rotate the divider by 0-360 degrees. So to get the rotation at a given date, we can take the difference between the chosen date and March 2nd, calculate the number of days, subtract any multiple of 29.5, divide that remainder by 29.5 and multiply it by 360. We then need to subtract that from 360 to fit our divider and the way CSS rotation works:

const getMoonPhaseRotation = date => {
  const cycleLength = 29.5 // days

  const knownNewMoon = new Date('2022-03-02 18:34:00')
  const secondsSinceKnownNewMoon = (date - knownNewMoon) / 1000
  const daysSinceKnownNewMoon = secondsSinceKnownNewMoon / 60 / 60 / 24
  const currentMoonPhasePercentage = (daysSinceKnownNewMoon % cycleLength) / cycleLength

  return 360 - Math.floor(currentMoonPhasePercentage * 360)
Enter fullscreen mode Exit fullscreen mode

Now, since the rotation of the disk doesn't automatically overlap the correct hemisphere (those are still light and dark) we need the light and the dark hemisphere to switch places, so it actually looks like the illuminated part is moving. For that we toggle around some classes based on the calculated rotation. We then also apply the styling for the rotation of the divider, et voila, a working moon phase visualizer.

const setMoonRotation = deg => {
  document.querySelector('.divider').style.transform = `rotate3d(0, 1, 0, ${deg}deg)`

  const hemispheres = document.querySelectorAll('.hemisphere')

  if (deg < 180) {
    // Left

    // Right
  } else {
    // Left

    // Right
Enter fullscreen mode Exit fullscreen mode

Lastly, we add a function to update the title:

const setMoonTitle = date => {
  document.querySelector('#date-title').innerHTML = `Moon phase for ${date.toUTCString()}`
Enter fullscreen mode Exit fullscreen mode

Tying things together

Now, let's make these functions work with each other:

const today = new Date()
const dateSelect = document.querySelector('input')

dateSelect.addEventListener('input', e => {
  const selectedDate = new Date(


dateSelect.value = today.toISOString().slice(0, 10)

Enter fullscreen mode Exit fullscreen mode


Bonus: Twinkle, twinkle, little star

Some stars around our moon would be nice, too, wouldn't they? Yes? Cool. Let's use a ton of linear gradients. Each linear gradient will have a bright spot that fades into the HTML's background color and will then get transparent. This way, they don't "overlap" each other and we don't need ton's of extra elements.

Let's see what I mean with a function to generate the gradient for a single star:

const getStar = () => {
  const x = Math.round(Math.random() * 100)
  const y = Math.round(Math.random() * 100)

  return `
    radial-gradient(circle at ${x}% ${y}%, 
    rgba(255,255,255,1) 0%, 
    rgba(11,14,58,1) 3px, 
    rgba(11,14,58,0) 5px, 
    rgba(11,14,58,0) 100%) no-repeat border-box
Enter fullscreen mode Exit fullscreen mode

As you can see, the star itself goes from #ffffff to rgba(11,14,58,1) for 3 pixels and gets transparent for another 2 pixels. The rest of this gradient is transparent. When you combine multiple gradients, the last one added "wins" and will overlap all the others. If parts of that gradient are transparent, the background can "shine" (haha) through. By making most of the linear gradient transparent, we can sprinkle a lot of them wherever we want.

Now we need to actually generate a lot of stars and add them to the body: = [...Array(100)].map(() => getStar()).join(', ')
Enter fullscreen mode Exit fullscreen mode

Aaaand done!

Demo time!

(Click on "Result" to see it in action)

Can't wait to check if the calculations are right! I hope we get a clear night tonight!

I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a ❤️ or a 🦄! I write tech articles in my free time and like to drink coffee every once in a while.

If you want to support my efforts, you can offer me a coffee or follow me on Twitter 🐦! You can also support me directly via Paypal!

Buy me a coffee button

Top comments (5)

kolja profile image
Kolja • Edited

Very nice idea😃
And now, please, a sundail theme 😏

thormeier profile image
Pascal Thormeier

Thank you so much! A sundial wouldn't be too far from this, actually, might do that next!

kolja profile image

Cool, with theme main colors depending on position of the sun?
Light theme at day, dark theme at night and fading between 😘

best_codes profile image
Best Codes

Do you have any idea how you would make the white part of the moon be a moon image? Here is my desired result:

Image description

Maybe I could use a canvas? I have no ideas...

jrbuckstegge profile image
João Ricardo

That's so cool! I wonder if it would be possible to adapt the code to work on Notion? Would love to have that information at hand.