← ui

aurora

lib

an aurora borealis palette any colour enters and travels, for glows and trails

npx shadcn add @ag/aurora
Already installed? Preview an update
npx shadcn add @ag/aurora --dry-run

Your installed source stays unchanged until you update it. How updates work

also called northern lights

shelf foundations

uses shade

the demo above, verbatim

aurora.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import { aurora } from "@/registry/base-nova/lib/aurora"
import { Travel } from "@/registry/base-nova/lib/shade.demo"

// #region aurora
/** Three people's own colours, each entering the aurora at its own place: the aurora's
 *  green, a person in blue, a person in orange (off the palette, so it enters at its
 *  nearest end). Left to right is the travel; above the whole aurora (span 1, one face
 *  alone), below a short travel (span 0.3, a crowd, each near its own colour). */
const PEOPLE = [
  { name: "green", css: "oklch(0.87 0.19 150)", hue: 150 },
  { name: "blue", css: "oklch(0.72 0.15 245)", hue: 245 },
  { name: "orange", css: "oklch(0.78 0.16 60)", hue: 60 },
]
const WHOLE = aurora(1)
const CROWD = aurora(0.3)
// #endregion

export default function AuroraDemo() {
  return (
    <Sample
      name="aurora"
      glance="wide"
      title="one colour, its aurora"
      description="span 1 above, 0.3 below"
      with="aurora"
    >
      <div className="dark space-y-3 rounded-xl bg-black p-4 text-foreground">
        {PEOPLE.map((p) => (
          <div key={p.name} className="flex items-center gap-4">
            <p className="w-12 font-mono text-muted-foreground text-xs">
              {p.name}
            </p>
            <div className="flex-1 space-y-1.5">
              <Travel shade={WHOLE} base={p} />
              <Travel shade={CROWD} base={p} />
            </div>
          </div>
        ))}
      </div>
    </Sample>
  )
}

the source, every file an install writes

aurora.ts
import type { Shade } from "@/registry/base-nova/lib/shade"

// AURORA: a colour effect (a lib/shade), the palette an aurora borealis moves through,
// entered from any one colour.
//
// An aurora's colours are its altitude and its energy: yellow-green at the lowest,
// brightest edge, green through most of the curtain, then aqua and blue, violet, and
// magenta at the top and where it flares. A curtain does not wear one of them, it
// TRAVELS them: brighter, more energetic light sits further along, and as light rises
// (and fades) it moves on. The palette is that order of hues (`AURORA.hues`).
//
// Any colour can enter it: a person's own colour finds its nearest place on the
// palette and moves from there. `span` says how much of the palette it may travel: 1
// the whole aurora (green to magenta, for one face alone on a stage), smaller the hues
// nearest its own (so a crowd of faces, each in its own colour, stays told apart). At
// the end of the palette it turns back, never wrapping from magenta to yellow-green.
//
// `x` is how far along the travel a thing is: 0 its own colour, 1 the farthest it may
// go (a peak, the oldest of a trail, the top of a glow). Lightness and chroma stay the
// base's, eased a little with the travel, so a pale colour makes a pale aurora.
//
// The colours are CSS relative colours (`oklch(from <base> …)`): the same in a
// stylesheet, an inline style or a canvas (resolve them once through
// `getComputedStyle` where a canvas needs plain colours). A browser without relative
// colours keeps the base.

export const AURORA = {
  /** The aurora's hues in the order it travels them (OKLCH degrees): yellow-green,
   *  green, aqua, blue, violet, magenta. */
  hues: [128, 150, 182, 245, 292, 330] as readonly number[],
  /** Lightness eased away over the whole travel; chroma kept at its far end. */
  lightness: 0.08,
  chroma: 0.85,
}

/** The signed shortest turn from hue `a` to hue `b`, degrees. */
const turn = (a: number, b: number) => ((b - a + 540) % 360) - 180

/** Where a hue sits on the palette (0 .. hues.length - 1, fractional): between the two
 *  hues it falls between, else at the end it is nearest. Exact, so travel starts AT the
 *  base's own hue, never with a jump to the nearest palette hue. */
export function placeOf(hue: number): number {
  const hues = AURORA.hues
  for (let i = 0; i < hues.length - 1; i++) {
    const span = turn(hues[i] as number, hues[i + 1] as number)
    const along = turn(hues[i] as number, hue)
    if (
      Math.sign(along) === Math.sign(span) &&
      Math.abs(along) <= Math.abs(span)
    )
      return i + along / span
  }
  const first = Math.abs(turn(hue, hues[0] as number))
  const last = Math.abs(turn(hue, hues[hues.length - 1] as number))
  return first <= last ? 0 : hues.length - 1
}

/** The hue at palette position `p` (fractional), between its two hues the short way. */
function hueAt(p: number): number {
  const hues = AURORA.hues
  const i = Math.min(hues.length - 2, Math.floor(p))
  const a = hues[i] as number
  return (a + turn(a, hues[i + 1] as number) * (p - i) + 360) % 360
}

/** The aurora's hue `x` (0..1) along a travel of `span` (0..1 of the palette) from
 *  `baseHue`: forward from its nearest place, turning back at the palette's end. */
export function auroraHue(baseHue: number, x: number, span: number): number {
  const last = AURORA.hues.length - 1
  let p = placeOf(baseHue) + Math.max(0, x) * Math.max(0, span) * last
  if (p > last) p = Math.max(0, 2 * last - p)
  return hueAt(p)
}

/** The aurora's colour `x` along its travel, entered from `base` (any CSS colour) whose
 *  hue is `baseHue`. A base off the palette (an orange) enters at its nearest end; a
 *  base on it travels from its own hue exactly. */
export function auroraColor(
  base: string,
  baseHue: number,
  x: number,
  span: number,
): string {
  const t = Math.min(1, Math.max(0, x))
  const hue = auroraHue(baseHue, t, span)
  const dl = -AURORA.lightness * t
  const c = 1 - (1 - AURORA.chroma) * t
  return `oklch(from ${base} calc(l + ${dl.toFixed(4)}) calc(c * ${c.toFixed(4)}) ${hue.toFixed(2)})`
}

/** The aurora as a shade (lib/shade), travelling `span` of the palette (default the
 *  whole of it): what a drawing that takes a shade wears. */
export function aurora(span = 1): Shade {
  if (!(span >= 0 && span <= 1))
    throw new Error(`aurora: span ${span}; it is a share of the palette, 0..1`)
  return {
    name: `aurora ${span}`,
    color: (base, baseHue, x) => auroraColor(base, baseHue, x, span),
  }
}