Same light,
different stuff.
Materials only change how a surface answers the light — sharpness of the hotspot, brightness of the rim, how much colour bleeds in. Shadows stay consistent across all of them, because they come from the same source. Hover to lift, press to push down.
Your site
has a time of day.
In sun mode Heliograph computes the solar altitude and azimuth for the visitor's location — guessed from the time zone, no permission prompt. Long amber shadows at 7 am, short crisp ones at noon, violet dusk, and a cool moon after dark. Try the slider in the dock.
Real projection
Shadows are cast with similar triangles, not guessed. Taller elements throw longer, softer shadows, and the direction changes across the screen with a point light.
Colour temperature
Light colour comes from black-body Kelvin. Shadows are tinted by the sky, not black: blue at noon, violet at sunset, the way painters always did it.
Cheap to run
One requestAnimationFrame loop that sleeps when nothing moves, batched layout reads, off-screen elements skipped, and only changed variables written.
Two attributes.
That's it.
Mark surfaces with data-hg="surface" and give them a height in pixels. Heliograph writes CSS variables; the bundled stylesheet turns them into shadows, highlights and rims — or use the variables yourself.
<link rel="stylesheet" href="heliograph.css"> <div data-hg="surface" data-hg-z="8" data-hg-z-hover="20" data-hg-material="glass"> A card lit like it's real. </div> <h1 data-hg="text">Shadowed type</h1> <div data-hg="lamp"></div> <!-- optional glow -->
import { createScene } from '@jihoo12/heliograph'; const scene = createScene({ mode: 'pointer', // or 'sun' | 'orbit' | 'fixed' light: { height: 700, color: 5600 }, }); // Let the real sun drive it — a day per minute scene.set({ mode: 'sun', sun: { speed: 1440 } }); // Or pin it to golden hour scene.setTime(18.5); // Drive your own canvas / WebGL from the same light scene.on('frame', (light) => draw(light));
A light, a table,
similar triangles.
Your page is a table seen from above. Each surface hovers z pixels over it. A light sits at height h. Where its rays clip the surface's edge and land on the table is where the shadow goes — the same rule for every element, so they all agree.
C is the element's centre, L the light's position on screen. For the sun, h → ∞ and the offset becomes z · cot(altitude) along the azimuth. Blur grows with z to fake the penumbra of an area light; four layers plus sky occlusion keep it soft.
What gets written
Per element, every frame something changes:
--hg-shadow | layered box-shadow |
--hg-text-shadow | for data-hg="text" |
--hg-lx --hg-ly | specular hotspot, local % |
--hg-angle | direction to the light, CSS deg |
--hg-spec --hg-facing | 0–1 strengths |
And once on <html>: --hg-light (rgb triple), --hg-intensity, --hg-sky-top, --hg-sky-bottom, --hg-shadow-rgb, --hg-night, --hg-light-x/y. This page's whole day/night palette is built from --hg-night.
The whole API.
Small on purpose. Everything else is CSS you already know.
| Attribute | Meaning |
|---|---|
data-hg="surface" | Casts a shadow, gets a specular hotspot and rim light. Uses ::before/::after. |
data-hg="text" | Casts a text-shadow. Combine: data-hg="surface text". |
data-hg="lamp" · "sky" | A glow that sits at the light; a background painted with the current sky. CSS only. |
data-hg-z · -z-hover · -z-active | Height in px at rest, hovered, pressed. Transitions are animated by the light loop. |
data-hg-material | matte · satin · gloss · glass · metal |
| Option | Default |
mode | 'pointer' · 'sun' · 'orbit' · 'fixed' |
light | { x: .5, y: .15, height: 700, color: 5600, intensity: 1 } |
sun | { date: null, lat: null, lon: null, speed: 0 } — nulls mean now / guessed from time zone |
shadow | { strength: 1, softness: 1, color: null } — null colour = tinted by sky |
sky · idle · smoothing | 35 · 'auto' · 7 |
| Method | |
set(opts) · setTime(date | hour) | Change anything live; jump the sun. |
on('frame', fn) | Get the light state each frame to drive canvas/WebGL. Returns unsubscribe. |
add(el) · remove(el) · refresh() · destroy() | New DOM is picked up automatically via MutationObserver. |