@circadian/luna
v0.1.3
Published
Lunar-aware React widgets that follow the real phase of the moon.
Maintainers
Readme
Lunar-aware React widgets that follow the real phase of the moon.
npm · GitHub · circadian.dev
Sol follows the sun. Luna follows the moon.
Most UIs stop at light mode and dark mode. Sol pushed that further — an interface that reacts to the real position of the sun. Luna is the other half of that story.
Luna computes the moon's real phase, illumination, and arc position from the user's location and current time, then transitions the interface through 8 lunar phases — new, waxing crescent, first quarter, waxing gibbous, full, waning gibbous, last quarter, and waning crescent — with smooth blends, arc-tracking orbs, and 10 richly designed skins.
No API key. No manual toggle. Your UI just follows the moon.
Luna is the companion package to @circadian/sol, and the foundation for the upcoming @circadian/ambient — a unified layer that morphs between Sol and Luna as the sun sets and rises.
bun add @circadian/luna
# or
npm install @circadian/luna
# or (Deno / Fresh)
deno add npm:@circadian/luna@circadian/luna gives you a full LunaWidget, a CompactLunaWidget, 10 skins, 8 lunar phases, and a dev-only phase + arc scrubber via LunaDevTools. Lunar position is computed locally from latitude, longitude, timezone, and current time using Meeus astronomical algorithms — no external API required.
Features
- 2 widget variants —
LunaWidget(full card) andCompactLunaWidget(slim pill/bar) - 10 skins —
foundry,paper,signal,meridian,mineral,aurora,tide,void,sundial,parchment - 8 lunar phases —
new,waxing-crescent,first-quarter,waxing-gibbous,full,waning-gibbous,last-quarter,waning-crescent - Real moon tracking — orb follows the moon's arc from moonrise → zenith → moonset
- Illumination display — real-time percentage (0% new moon → 100% full moon)
- Moonrise/moonset times — computed locally, no API
- Built-in fallback strategy — geolocation → browser timezone → timezone centroid
- Dev preview tooling —
LunaDevToolslets you pick phases and scrub the arc position - SSR-safe — works in Next.js, Remix, TanStack Start, Blade, Fresh, and Vite
Quick Start
Luna uses browser APIs for geolocation and lunar computation. The exact setup depends on your framework — pick yours below.
Vite
No special setup needed. Wrap your app with the provider and use widgets directly.
// main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { LunaThemeProvider } from '@circadian/luna';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<LunaThemeProvider initialDesign="void">
<App />
</LunaThemeProvider>
</StrictMode>,
);// App.tsx
import { LunaWidget } from '@circadian/luna';
export default function App() {
return <LunaWidget showIllumination showMoonrise />;
}Next.js (App Router)
Add 'use client' at the top of any file that uses Luna. This marks it as a client component and prevents it from running during server rendering.
// components/providers.tsx
'use client';
import { LunaThemeProvider } from '@circadian/luna';
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<LunaThemeProvider initialDesign="void">
{children}
</LunaThemeProvider>
);
}// components/luna-widget.tsx
'use client';
import { LunaWidget } from '@circadian/luna';
export default function Luna() {
return <LunaWidget showIllumination showMoonrise />;
}// app/layout.tsx
import Providers from '../components/providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}// app/page.tsx
import Luna from '../components/luna-widget';
export default function Page() {
return <Luna />;
}Remix
Name any file that uses Luna with a .client.tsx extension. Remix excludes .client files from the server bundle automatically.
// app/components/providers.client.tsx
import { LunaThemeProvider } from '@circadian/luna';
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<LunaThemeProvider initialDesign="void">
{children}
</LunaThemeProvider>
);
}// app/components/luna-widget.client.tsx
import { LunaWidget } from '@circadian/luna';
export default function Luna() {
return <LunaWidget showIllumination showMoonrise />;
}// app/root.tsx
import Providers from './components/providers.client';
export default function App() {
return (
<html lang="en">
<body>
<Providers>
<Outlet />
</Providers>
</body>
</html>
);
}// app/routes/_index.tsx
import Luna from '../components/luna-widget.client';
export default function Index() {
return <Luna />;
}TanStack Start
Use the ClientOnly component from @tanstack/react-router to prevent Luna from rendering during SSR.
// app/components/luna-widget.tsx
import { ClientOnly } from '@tanstack/react-router';
import { LunaThemeProvider, LunaWidget } from '@circadian/luna';
export default function Luna() {
return (
<ClientOnly fallback={null}>
<LunaThemeProvider initialDesign="void">
<LunaWidget showIllumination showMoonrise />
</LunaThemeProvider>
</ClientOnly>
);
}// app/routes/index.tsx
import Luna from '../components/luna-widget';
export const Route = createFileRoute('/')({
component: () => <Luna />,
});Blade
Name any file that uses Luna with a .client.tsx extension. Blade runs pages server-side; component files run client-side.
// components/providers.client.tsx
import { LunaThemeProvider } from '@circadian/luna';
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<LunaThemeProvider initialDesign="void">
{children}
</LunaThemeProvider>
);
}// components/luna-widget.client.tsx
import { LunaWidget } from '@circadian/luna';
export default function Luna() {
return <LunaWidget showIllumination showMoonrise />;
}// pages/layout.tsx
import Providers from '../components/providers.client';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <Providers>{children}</Providers>;
}// pages/index.tsx
import Luna from '../components/luna-widget.client';
export default function Page() {
return <Luna />;
}Fresh (v2)
Fresh uses Preact, so Luna works via preact/compat. Add the React compatibility aliases to your vite.config.ts and deno.json, then create an island for the widget.
1. Install
deno add npm:@circadian/luna2. Configure Vite aliases — add resolve.alias to vite.config.ts:
// vite.config.ts
import { defineConfig } from "vite";
import { fresh } from "@fresh/plugin-vite";
export default defineConfig({
plugins: [fresh()],
resolve: {
alias: {
"react": "preact/compat",
"react-dom": "preact/compat",
"react/jsx-runtime": "preact/jsx-runtime",
"react/jsx-dev-runtime": "preact/jsx-runtime",
"react-dom/client": "preact/compat/client",
},
},
});3. Add import map entries — add to the "imports" in deno.json:
// deno.json (imports section)
{
"imports": {
"react": "npm:preact@^10.27.2/compat",
"react-dom": "npm:preact@^10.27.2/compat",
"react/jsx-runtime": "npm:preact@^10.27.2/jsx-runtime",
"react-dom/client": "npm:preact@^10.27.2/compat/client"
}
}4. Create an island — islands are client-hydrated in Fresh, which is what Luna needs:
// islands/LunaWidget.tsx
import { LunaThemeProvider, LunaWidget } from '@circadian/luna';
export default function LunaIsland() {
return (
<LunaThemeProvider initialDesign="void">
<LunaWidget showIllumination showMoonrise />
</LunaThemeProvider>
);
}5. Use it in a route:
// routes/index.tsx
import { define } from "../utils.ts";
import LunaIsland from "../islands/LunaWidget.tsx";
export default define.page(function Home() {
return <LunaIsland />;
});Provider Props
LunaThemeProvider is the shared runtime for lunar phase computation, timezone, coordinates, and skin selection.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Required |
| initialDesign | DesignMode | 'void' | Starting skin |
| latitude | number \| null | — | Override latitude |
| longitude | number \| null | — | Override longitude |
| timezone | string \| null | — | Override timezone |
Location is automatic
LunaThemeProvider resolves the user's location using the same 3-step fallback as Sol:
- Browser Geolocation API — most accurate, requires user permission
- Browser timezone (
Intl.DateTimeFormat) — instant, no permission needed - Timezone centroid lookup — maps the IANA timezone to approximate coordinates
Lunar phases and moonrise/moonset times are computed locally using Meeus astronomical algorithms — no external API required.
LunaWidget
The full card widget. Reads its design from the nearest LunaThemeProvider.
<LunaWidget
expandDirection="top-left"
size="lg"
showIllumination
showMoonrise
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| expandDirection | LunaExpandDirection | 'bottom-right' | Direction the card expands |
| size | LunaWidgetSize | 'lg' | Widget size (xs, sm, md, lg, xl) |
| showIllumination | boolean | true | Show illumination percentage |
| showMoonrise | boolean | true | Show moonrise/moonset times |
| simulatedDate | Date | — | Simulate a specific time |
| forceExpanded | boolean | — | Lock expanded or collapsed state |
| hoverEffect | boolean | false | Enable hover animation |
| className | string | — | Wrapper CSS class |
CompactLunaWidget
The slim pill/bar variant. Always uses the provider's active skin.
<CompactLunaWidget
size="md"
showIllumination
showMoonrise
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | CompactLunaSize | 'md' | Compact size (sm, md, lg) |
| showIllumination | boolean | true | Show illumination percentage |
| showMoonrise | boolean | true | Show moonrise/moonset times |
| simulatedDate | Date | — | Simulate a time |
| className | string | — | Wrapper CSS class |
Skins
10 designs, each with a full widget and compact variant. Luna shares the same skin names as Sol — when used together in @circadian/ambient, both widgets use the same active skin.
type DesignMode =
| 'aurora' // luminous ethereal
| 'foundry' // cold steel by moonlight
| 'tide' // fluid organic wave
| 'void' // minimal negative space
| 'mineral' // faceted crystal gem
| 'meridian' // hairline geometric
| 'signal' // pixel/blocky lo-fi
| 'paper' // silver ink on dark stock
| 'sundial' // classical carved latin
| 'parchment'; // manuscript illustratedLunaDevTools
When your interface depends on the live moon, manual testing is impossible — you can't wait two weeks for a full moon. LunaDevTools gives you two independent controls: a phase picker (8 lunar phases) and an arc slider (moonrise → zenith → moonset), so you can preview every phase and every orb position instantly.
Imported from a dedicated subpath — never included in production bundles unless explicitly imported.
import { LunaDevTools } from '@circadian/luna/devtools';
// Vite
{import.meta.env.DEV && <LunaDevTools />}
// Next.js / Remix / TanStack Start / Blade
{process.env.NODE_ENV === 'development' && <LunaDevTools />}Full example
import { LunaThemeProvider, LunaWidget } from '@circadian/luna';
import { LunaDevTools } from '@circadian/luna/devtools';
export default function Demo() {
return (
<LunaThemeProvider initialDesign="void">
<LunaWidget showIllumination showMoonrise />
{process.env.NODE_ENV === 'development' && (
<LunaDevTools position="bottom-center" />
)}
</LunaThemeProvider>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultOpen | boolean | false | Start expanded |
| position | 'bottom-left' \| 'bottom-center' \| 'bottom-right' | 'bottom-left' | Panel position |
| enabled | boolean | true | Programmatic enable/disable |
useLunaTheme
import { useLunaTheme } from '@circadian/luna';
function DebugPanel() {
const { phase, illumination, moonProgress, isVisible, design } = useLunaTheme();
return (
<pre>{JSON.stringify({ phase, illumination, moonProgress, isVisible, design }, null, 2)}</pre>
);
}Return shape
| Property | Type | Description |
|---|---|---|
| phase | LunarPhase | Current lunar phase |
| illumination | number | 0–1 illumination (0 = new, 1 = full) |
| ageInDays | number | 0–29.53 days into the lunar cycle |
| daysUntilFull | number | Days until next full moon |
| daysUntilNew | number | Days until next new moon |
| moonriseMinutes | number \| null | Moonrise in minutes from midnight |
| moonsetMinutes | number \| null | Moonset in minutes from midnight |
| moonProgress | number | 0–1 arc position (0 = rise, 0.5 = zenith, 1 = set) |
| isVisible | boolean | Whether the moon is above the horizon |
| isReady | boolean | Whether lunar data has computed |
| design | DesignMode | Active skin name |
| activeSkin | LunaSkinDefinition | Full skin definition object |
| accentColor | string | Active accent hex |
| setDesign | (skin: DesignMode) => void | Change active skin |
| setOverridePhase | (phase \| null) => void | Set/clear phase override |
| setSimulatedDate | (date \| undefined) => void | Set/clear simulated date |
useLunarPosition
Standalone hook — no provider required. Use this to build your own lunar-aware components.
import { useLunarPosition } from '@circadian/luna';
function MoonInfo() {
const moon = useLunarPosition();
return (
<div>
<p>Phase: {moon.phase}</p>
<p>Illumination: {Math.round(moon.illumination * 100)}%</p>
<p>Visible: {moon.isVisible ? 'Yes' : 'No'}</p>
</div>
);
}Options
| Option | Type | Default | Description |
|---|---|---|---|
| latitude | number \| null | 51.5074 | Latitude |
| longitude | number \| null | -0.1278 | Longitude |
| timezone | string \| null | 'Europe/London' | IANA timezone |
| updateIntervalMs | number | 60000 | Update interval in ms |
| simulatedDate | Date | — | Override current time |
Multiple Widgets
<LunaThemeProvider initialDesign="void">
<LunaWidget showIllumination />
<CompactLunaWidget />
<LunaWidget showMoonrise />
</LunaThemeProvider>The provider manages shared lunar state — location, phase, and position are computed once and shared across all children.
TypeScript
import type {
LunarPhase,
LunarPosition,
LunaThemeContext,
LunaSkinDefinition,
LunarPalette,
LunarSkinPalettes,
LunaWidgetProps,
LunaExpandDirection,
LunaWidgetSize,
CompactLunaWidgetPublicProps,
CompactLunaSize,
} from '@circadian/luna';What's Included
| | | |---|---| | ✅ | Full widget + compact widget | | ✅ | 10 skins with full + compact variants | | ✅ | Lunar math (Meeus algorithms, no external API) | | ✅ | Real moonrise/moonset computation | | ✅ | Moon arc tracking (rise → zenith → set) | | ✅ | Illumination percentage | | ✅ | Timezone fallback logic | | ✅ | Dev phase + arc scrubber | | ✅ | Self-contained CSS (no Tailwind required in host app) | | ✅ | SSR-safe (Next.js, Remix, TanStack Start, Blade, Fresh, Vite) | | ❌ | No API key needed | | ❌ | No Tailwind needed in your app | | ❌ | No geolocation permission required |
Coming Soon
Luna is the second piece of the Circadian platform. Next up:
@circadian/ambient— a unified layer that morphs between Sol and Luna automatically as the sun sets and rises, with crossfade transitions and synced skin selection- More skins
- Vue and Svelte adapters
MIT © Circadian
