← Loaders

Moon

<MoonLoader /> The moon running through its phases.

Animated · Source · Markdown

Loading…
Theme
Usage
<MoonLoader
  size={96}
/>
Props and theme tokens
PropTypeDefaultDescription
themePartial<MoonLoaderTheme>—Colour overrides. Keys listed under theme tokens.
sizenumber64Width and height in pixels.
labelstring"Loading…"Announced to screen readers through role="status".
pausedbooleanfalseFreezes the animation.
classNamestring—Applied to the root element (an inline-flex span).
styleCSSProperties—Merged into the root element style, after theme variables.
Theme keyCSS variableDefault
moon--moon#f2d98c
crater--crater#e2c574
outline--outline#c9d3dc
stars--stars#f2c14e
Source files

Copy into components/loaders/. The shared files are needed once for all loaders.

components/loaders/moon-loader.tsx
// <MoonLoader>: the moon running through its phases among twinkling stars. The lit part is the disc
// minus a shadow disc that slides across it, so it works on any background; a faint outline keeps
// the new moon visible.

import { useId } from 'react';
import { LoaderFrame, type LoaderProps } from './loader';
import styles from './moon-loader.module.css';

export const moonLoaderTheme = {
  moon: '#f2d98c',
  crater: '#e2c574',
  outline: '#c9d3dc',
  stars: '#f2c14e',
};
export type MoonLoaderTheme = typeof moonLoaderTheme;

const R = 26;

/** The moon running through its phases among twinkling stars. */
export function MoonLoader(props: LoaderProps<MoonLoaderTheme>) {
  const id = `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
  return (
    <LoaderFrame
      {...props}
      defaultTheme={moonLoaderTheme}
      art={
        <>
          <defs>
            {/* White shows the lit part: the disc, minus a black disc that slides across it */}
            <mask id={`${id}phase`} maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
              <circle cx="50" cy="50" r={R} fill="#ffffff" />
              <g className={styles.shadow}>
                <circle cx="50" cy="50" r={R + 1} fill="#000000" />
              </g>
            </mask>
          </defs>

          <circle cx="50" cy="50" r={R} fill="none" stroke="var(--outline)" strokeWidth="1.5" strokeDasharray="2 3" />
          <g mask={`url(#${id}phase)`} className={styles.tilt}>
            <circle cx="50" cy="50" r={R} fill="var(--moon)" />
            <g fill="var(--crater)">
              <circle cx="42" cy="42" r="5" />
              <circle cx="59" cy="57" r="6.5" />
              <circle cx="56" cy="38" r="3" />
              <circle cx="41" cy="61" r="3.5" />
            </g>
          </g>

          <g fill="var(--stars)">
            {[
              { x: 14, y: 22, s: 1, d: 0 },
              { x: 86, y: 18, s: 0.8, d: -0.6 },
              { x: 88, y: 78, s: 1.1, d: -1.2 },
              { x: 12, y: 80, s: 0.7, d: -1.8 },
            ].map((st, i) => (
              <g key={i} transform={`translate(${st.x} ${st.y}) scale(${st.s})`}>
                <path className={styles.star} style={{ animationDelay: `${st.d}s` }} d="M0 -6 L1.5 -1.5 L6 0 L1.5 1.5 L0 6 L-1.5 1.5 L-6 0 L-1.5 -1.5 Z" />
              </g>
            ))}
          </g>
        </>
      }
    />
  );
}
components/loaders/moon-loader.module.css
/* One 2.4s cycle: the shadow slides from fully covering the disc (new moon) off to the right
   (full), then reappears off the left and slides back over it. The jump from right to left
   happens while both positions are clear of the disc, so the loop is seamless. */
.shadow { animation: phase 2.4s linear infinite; }

.tilt {
  transform-box: fill-box;
  transform-origin: center;
  animation: tilt 4.8s ease-in-out infinite alternate;
}

.star {
  transform-box: fill-box;
  transform-origin: center;
  animation: twinkle 2.4s ease-in-out infinite;
}

@keyframes phase {
  0%      { transform: translateX(0); }
  45%     { transform: translateX(60px); }
  50%     { transform: translateX(60px); }
  50.01%  { transform: translateX(-60px); }
  55%     { transform: translateX(-60px); }
  100%    { transform: translateX(0); }
}
@keyframes tilt {
  from { transform: rotate(-8deg); }
  to   { transform: rotate(8deg); }
}
@keyframes twinkle {
  0%, 100% { transform: scale(0.5); opacity: 0.3; }
  50%      { transform: scale(1); opacity: 1; }
}
components/loaders/loader.tsx
// Shared frame for every loader: theming, sizing and an accessible status label.
// Loaders are pure server components: no client JavaScript.

import type { CSSProperties, ReactNode } from 'react';
import styles from './loader.module.css';

/** Props every loader accepts. `T` is the loader's theme (colour tokens). */
export type LoaderProps<T> = {
  /** Override any of the loader's colours. */
  theme?: Partial<T>;
  /** Width and height in pixels (default 64). */
  size?: number;
  /** Text announced to screen readers (default "Loading…"). */
  label?: string;
  /** Freeze the animation. */
  paused?: boolean;
  className?: string;
  style?: CSSProperties;
};

// waterTop → --water-top, leaf1 → --leaf-1
const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`;

type LoaderFrameProps<T extends Record<string, string>> = LoaderProps<T> & {
  defaultTheme: T;
  /** SVG content drawn in a 100×100 box. */
  art: ReactNode;
};

export function LoaderFrame<T extends Record<string, string>>({
  defaultTheme,
  art,
  theme,
  size = 64,
  label = 'Loading…',
  paused,
  className,
  style,
}: LoaderFrameProps<T>) {
  const vars: Record<string, string> = {};
  for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) {
    if (value) vars[toVar(key)] = value;
  }
  const classes = [styles.root, paused && styles.paused, className].filter(Boolean).join(' ');

  return (
    <span role="status" className={classes} style={{ ...vars, ...style } as CSSProperties}>
      <svg className={styles.art} width={size} height={size} viewBox="0 0 100 100" aria-hidden="true" focusable="false">
        {art}
      </svg>
      <span className={styles.label}>{label}</span>
    </span>
  );
}
components/loaders/loader.module.css
.root {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  line-height: 0;
}

.art {
  display: block;
  overflow: visible;
}

/* Visible to screen readers only. */
.label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.paused * { animation-play-state: paused !important; }

/* A loader that stops moving looks broken, so reduced motion swaps the
   choreography for a slow fade that still reads as "working". */
@media (prefers-reduced-motion: reduce) {
  .art * { animation: none !important; }
  .art { animation: breathe 2.4s ease-in-out infinite; }
}

@keyframes breathe {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.45; }
}