shadows that follow the real sun. and at night, the real moon.
what it does · install · quickstart · api · accuracy · the sundial · the numbers · how the math works
one day over san francisco. drag it yourself
computes where the sun actually is, for your latitude, longitude, and the current
moment, and writes the result onto the page as four css custom properties. your
existing box-shadow and text-shadow rules read them, and every shadow on the
page follows the real light: short and dark at noon, a long rake off to one side
on a summer evening. after sunset the moon takes over, fainter, scaled by how
much of its disc is lit. when nothing is up, shadows settle to a short neutral
offset instead of disappearing.
no network calls, no permissions, no dependencies. the astronomy is computed
locally from the clock, so it works offline, in node, and in any framework or
none. 3.9 kB gzipped as an es module, 3.2 kB as the script-tag build
(checked by npm run size).
it also stamps data-sky="day|golden|dusk|night" and data-sun="up|down" on the
root element, which makes sun-driven theming (golden-hour tints, a dark mode that
flips at actual sunset) one css selector.
npm i real-shadowspnpm, yarn, and bun work the same way (pnpm add, yarn add, bun add).
or skip the install entirely, straight from a cdn, no build step:
<script src="https://cdn.jsdelivr.net/npm/real-shadows@1"></script>
<script>
RealShadows.realShadows({ lat: 37.77, lon: -122.42 });
</script>@1 pins the major version, so you get fixes but never a breaking change.
want to see the numbers before installing anything? npx runs a package's
command without installing it, so this prints the shadow being cast over a
location right now:
npx real-shadows 37.77 -122.42when 2026-07-27T19:39:03.867Z
where 37.77, -122.42
casting sun
sky day (sun 69.6deg, up)
caster alt 69.6deg, az 154.0deg
offset 2.05px, 4.51px
blur 0px
opacity 0.95
tint rgb(0 0 0)
moon 97% lit, waxing
sundial aim device top 178deg (s)
css: box-shadow: 2.05px 4.51px 0px rgb(0 0 0 / 0.95);
real output, midday over san francisco: the sun nearly overhead at 69.6 degrees, so the shadow is short and falls almost straight down.
--at <iso-date> asks about another moment. ten hours later, after dark:
npx real-shadows 37.77 -122.42 --at 2026-07-28T05:30:00Zcasting moon
sky night (sun -21.1deg, down)
caster alt 21.5deg, az 154.7deg
offset 3.82px, 5.53px
blur 1px
opacity 0.3
moon 98% lit, waxing
sundial aim device top 189deg (s)
the caster hands off to the moon on its own. opacity drops from 0.95 to 0.3, and
the edge picks up a 1px blur because the moon is low. --json gives the same
thing machine-readable.
one call, one css line:
import { realShadows } from 'real-shadows';
realShadows({ lat: 37.77, lon: -122.42 });.card {
box-shadow: var(--rs-x) var(--rs-y) var(--rs-blur) rgb(0 0 0 / var(--rs-alpha));
}that's the whole integration. the library refreshes every five minutes (the movement between refreshes is sub-pixel, so nothing visibly jumps), recomputes when a laptop wakes, and pauses while the tab is hidden.
theming hooks, if you want them:
[data-sky='golden'] body { background: #f2e9d8; } /* golden hour tint */
[data-sun='down'] body { background: #17171c; } /* dark when the sun is actually down */the library writes one offset, the shadow cast by something sitting on the page.
design systems usually want a scale, and a scale falls out of multiplying it: a
thing twice as far off the page throws a shadow twice as long. calc() does the
whole job, no extra api.
:root {
--sm: 0.6; /* resting on the surface */
--md: 1; /* the library's own offset */
--lg: 2.2; /* lifted, a modal or a drag */
}
.card { box-shadow: calc(var(--rs-x) * var(--sm)) calc(var(--rs-y) * var(--sm))
var(--rs-blur) rgb(0 0 0 / var(--rs-alpha)); }
.dialog { box-shadow: calc(var(--rs-x) * var(--lg)) calc(var(--rs-y) * var(--lg))
calc(var(--rs-blur) * var(--lg)) rgb(0 0 0 / var(--rs-alpha)); }every tier stays on the same sun, so the whole page reads as one light source at one moment. that is the part a static shadow scale cannot do.
the library never asks for a location. you pass one in; the three usual sources:
// 1. hardcode a place (a city center is plenty; accuracy is city-level anyway)
realShadows({ lat: 51.51, lon: -0.13 });
// 2. ask the browser (requires a permission prompt)
navigator.geolocation.getCurrentPosition((p) =>
realShadows({ lat: p.coords.latitude, lon: p.coords.longitude }),
);
// 3. an ip-geo service you already use, or your server's geo headersstarts the loop, writes the variables, returns a handle.
| option | default | meaning |
|---|---|---|
lat, lon |
required | decimal degrees; north and east positive. |
element |
document.documentElement |
where the variables and attributes are written. |
prefix |
'rs' |
custom property prefix: --rs-x, --rs-y, --rs-blur, --rs-alpha. |
interval |
300000 |
refresh cadence in ms. five minutes moves the offset well under a pixel. |
attributes |
true |
also stamp data-sky and data-sun. |
now |
live clock | a fixed Date, or a () => Date, for demos and time scrubbers. |
onUpdate |
none | called with each written ShadowVector. |
moon |
true |
let the moon cast at night; false goes straight to the neutral shadow. |
minLength, maxLength |
3.8, 11.4 |
offset in px at zenith and at the horizon. |
facing |
'auto' |
which horizon the viewer faces; see below. |
tint |
false |
also write --rs-tint, an r g b triplet: neutral black under the sun, cool blue-gray under the moon. moonlight is physically a touch redder than sunlight, but night vision is rod-driven and blue-biased (the purkinje shift), so moonlit scenes read cold. use it as box-shadow: ... rgb(var(--rs-tint) / var(--rs-alpha)). |
the handle: refresh() recomputes now, setLocation(lat, lon) moves,
current() returns the last vector, stop() clears everything it wrote.
calling realShadows() during server rendering is a safe no-op.
all pure, no dom, importable anywhere: canvas, webgl, or your own applier.
| function | returns |
|---|---|
shadowVector(date, lat, lon, opts?) |
{ source, dx, dy, blur, alpha, altitude, azimuth, sunAltitude, intensity } |
skyPhase(date, lat, lon) |
{ sky, sunUp, sunAltitude } |
shadowBearing(date, lat, lon) |
{ degrees, direction, source } or null. see the sundial |
sunPosition(date, lat, lon) |
{ altitude, azimuth } degrees; azimuth clockwise from north |
moonPosition(date, lat, lon) |
{ altitude, azimuth }, parallax-corrected |
moonIllumination(date) |
{ fraction, phase, waxing } |
a screen is a vertical plane, so only the light's east or west lean can be shown.
to lean the right way the model assumes you face the equator: south in the
northern hemisphere, north in the southern, which is where the sun spends the
day. facing: 'south' | 'north' overrides the guess.
note: the vertical component is a stylisation. on-screen shadows always fall down the page, deepest at noon, shallow near the horizon. physically correct verticals would send shadows up the screen for half the day, which reads as broken, not accurate. the horizontal lean, the length, the opacity, and the day-to-night handoff are all real.
positions come from a compact classical ephemeris (schlyter), checked in ci against astronomy-engine, a jpl-derived reference good to arcseconds. the grid: 13 places from longyearbyen (78°n) to mcmurdo (78°s), sampled across two years.
| body | max error | mean error |
|---|---|---|
| sun | 0.013° | 0.007° |
| moon | 0.085° | 0.029° |
| moon illumination | 0.0006 | n/a |
0.013 degrees is about 47 arcseconds, a fortieth of the sun's own disc. for a shadow offset quantised to hundredths of a pixel, anything past a tenth of a degree is invisible; the extra precision is free, so it ships. the test suite asserts sun < 0.05° and moon < 0.2° on every commit, so the table above is enforced, not aspirational.
the moon's position includes topocentric parallax, the roughly one-degree shift from the observer standing on the earth's surface rather than at its centre. that shift is the difference between "the moon has risen" and "not yet".
two conventions worth knowing. all returned altitudes are geometric:
atmospheric refraction (about half a degree of lift at the horizon) is exported
as refraction(altitude) for anyone who wants apparent altitudes, but is not
baked into positions, so a comparison against a sunrise app will differ by
about that much right at the horizon (the day-to-night handoff does use the
refracted threshold of −0.833°, so shadows still switch at sunset as your eyes
see it). and on unusable coordinates skyPhase falls back to
{ sky: 'day', sunUp: true }; an auto dark mode built on it fails light,
not dark.
shadowBearing(date, lat, lon) returns the compass bearing to point your
device's top edge so the shadows on screen line up with the real shadows on
your desk:
const b = shadowBearing(new Date(), 37.77, -122.42);
// { degrees: 149.2, direction: 'sse', source: 'sun' }aim the top of the phone at 149° and the shadow under a card on screen runs parallel to the shadow under your coffee cup. the round trip (device bearing plus on-screen shadow angle equals the true shadow bearing) closes within a hundredth of a degree in the test suite, over 500 random places and moments.
the bearing is yours: it depends on where you stand and when you ask, and it moves through the day as the sun does. that is not drift, it is the point — the on-screen vertical is stylised (always down the page), so the rotation that reconciles screen and sky has to change as the light swings. same formula, same number, as the sundial on zayd.wtf.
everything the library reports — in the demo readout and in
npx real-shadows — with the line of math that produces it, so any number on
the screen can be checked by hand:
| number | the line | proven by |
|---|---|---|
| caster alt/az | schlyter's classical ephemeris; moon topocentric | oracle in ci vs astronomy-engine: sun < 0.05°, moon < 0.2° |
| sky | sun altitude bands: ≥ 6° day, ≥ −6° golden, ≥ −12° dusk, else night | handoff tests at fixed dates |
| length | min + (max − min) · (1 − alt/90)^1.45 |
monotonic-in-altitude unit test |
| offset x | ±sin(azimuth) · length — the light's true east-west lean; sign from facing |
model units, signs checked in the demo |
| offset y | (0.4 + 0.6 · sin(alt)) · length — stylised, always down the page |
model units |
| opacity | (0.17 + 0.83 · sin(alt)) · intensity, clamped 0–1 |
clamp tests |
| blur | round((1 − sin alt)² · 2) px |
bounds test |
| moon intensity | lunar phase law (allen), fourth-root compressed: full 1.0, half ~0.55 | scaling test |
| sundial | (azimuth + 180) − atan2(dx, −dy), mod 360 |
round trip < 0.01° over 500 random places and moments |
the handoff rule threading them together: sun casts down to −0.833° (the
refracted horizon), then the moon if it is up, else the neutral fallback
(minLength, minLength, minAlpha).
three steps, all local arithmetic:
-
ephemeris. the sun's and moon's orbital elements are propagated from the j2000 epoch (schlyter's method: kepler's equation, one newton step, the twelve largest lunar perturbations), then rotated through the ecliptic and the observer's sidereal time into altitude and azimuth. the moon's altitude is then dropped by its parallax. ~200 lines, no lookup tables.
-
the lighting model. altitude sets the length,
min + (max−min) · (1−alt/90)^1.45, the exponent leaning the stretch toward the horizon so a low sun rakes dramatically while noon stays short. altitude also sets the opacity, which breathes from 1.0 overhead down to 0.17 at the horizon. azimuth sets the lean. edges stay hard except within a couple of degrees of the horizon, where real shadows genuinely diffuse (blur ≤ 2px). -
the handoff. when the sun drops below −0.833° (the refracted horizon: sunset as your eyes define it), the moon takes over if it's up. its strength follows the real lunar phase law (a half moon is 9% as bright as full, not 50: rough terrain shadows itself at every angle except opposition), compressed with a fourth root so partial phases stay visible, times a 0.7 stylisation factor. real moonlight is five orders of magnitude fainter than sunlight; rendering that honestly would render nothing.
not a sunrise/sunset calendar (no event times, no eclipses; use suncalc or astronomy-engine for that), not a soft-shadow renderer (offsets stay crisp by design), not a geolocation library (bring your own coordinates), and not sub-arcsecond astronomy (it is a lighting model with honest inputs).
zayd / cold
zayd.wtf · twitter · github
icarus only fell because he flew