Skip to content
UI Kit
Accent color

Aurora

Soft veils of light in the accent color that drift slowly. Quiet in light mode, luminous in dark mode.

One of 6 components in Backgrounds

3 variants

Variants

Soft from the top

A gentle light hangs from the top edge, made for announcements.

Vivid in the middle

Stronger veils behind the headline for a product teaser.

Horizon

The light rises from the bottom edge like a morning sky.

Details

What Aurora brings along, read from the registry and the source.

Category
Backgrounds
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/aurora.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The AuroraProps interface from components/ui/aurora.tsx, with the comments from the code.

  • intensityoptional
    AuroraIntensity

    softmediumvivid

    How strong the veils are. Light mode always stays quieter than dark.

  • placementoptional
    AuroraPlacement

    topcenterbottom

    Where the light hangs: from the top edge, behind the middle or rising from the bottom

  • speedoptional
    number

    Seconds for one slow sway of the veils, default 22

  • parallaxoptional
    number

    Pixels the veils drift against the page over a full scroll pass, a quiet depth. Default 36, 0 is off.

Accessibility

Read from the source of the component.

  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 174 lines
components/ui/aurora.tsx
'use client';

import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

export type AuroraIntensity = 'soft' | 'medium' | 'vivid';
export type AuroraPlacement = 'top' | 'center' | 'bottom';

export interface AuroraProps {
  /** How strong the veils are. Light mode always stays quieter than dark. */
  intensity?: AuroraIntensity;
  /** Where the light hangs: from the top edge, behind the middle or rising from the bottom */
  placement?: AuroraPlacement;
  /** Seconds for one slow sway of the veils, default 22 */
  speed?: number;
  /** Pixels the veils drift against the page over a full scroll pass, a quiet depth. Default 36, 0 is off. */
  parallax?: number;
  className?: string;
}

const CSS = `
@keyframes kitfx-aurora-sway {
  0%, 100% { transform: translate3d(-5%, 0, 0) rotate(var(--kitfx-r, 0deg)) scaleY(1); }
  50% { transform: translate3d(5%, var(--kitfx-y, 8%), 0) rotate(calc(var(--kitfx-r, 0deg) * -0.6)) scaleY(1.25); }
}
@keyframes kitfx-aurora-breathe { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
.kitfx-aurora * { animation-play-state: var(--kitfx-play, running); }
@media (prefers-reduced-motion: reduce) { .kitfx-aurora * { animation: none !important; } .kitfx-aurora-depth { transform: none !important; } }

/* Tones. Fallback without relative colors: thin mixes of the accent */
.kitfx-aurora {
  --kitfx-a1: color-mix(in oklab, var(--primary) 22%, transparent);
  --kitfx-a2: color-mix(in oklab, var(--primary) 11%, transparent);
  --kitfx-a3: color-mix(in oklab, var(--primary) 16%, transparent);
}
.dark .kitfx-aurora {
  --kitfx-a1: color-mix(in oklab, var(--primary) 50%, transparent);
  --kitfx-a2: color-mix(in oklab, var(--primary) 26%, transparent);
  --kitfx-a3: color-mix(in oklch, color-mix(in oklab, var(--primary) 70%, var(--foreground)) 36%, transparent);
}
/*
 * --kitfx-n says how neutral the accent is: 1 for the grey default, 0 for any
 * real color. Grey veils on white look like smudges, so a neutral accent turns
 * into a pearl light: mid lightness, a breath of chroma, and the veils fan out
 * in hue (lavender, rose, sky). A colored accent keeps its hue and strength.
 */
@supports (color: oklch(from red l c h)) {
  .kitfx-aurora {
    --kitfx-n: clamp(0, 1 - c / 0.06, 1);
    --kitfx-a1: oklch(from var(--primary) calc(l + (0.72 - l) * var(--kitfx-n)) max(c, 0.08) h / calc(0.22 + 0.16 * var(--kitfx-n)));
    --kitfx-a2: oklch(from var(--primary) calc(l + (0.78 - l) * var(--kitfx-n)) max(c, 0.075) calc(h + 60 * var(--kitfx-n)) / calc(0.11 + 0.2 * var(--kitfx-n)));
    --kitfx-a3: oklch(from var(--primary) calc(l + (0.78 - l) * var(--kitfx-n)) max(c, 0.07) calc(h - 78 * var(--kitfx-n)) / calc(0.16 + 0.17 * var(--kitfx-n)));
  }
  .dark .kitfx-aurora {
    --kitfx-a1: oklch(from var(--primary) calc(l + (0.8 - l) * var(--kitfx-n)) max(c, 0.07) h / 0.5);
    --kitfx-a2: oklch(from var(--primary) calc(l + (0.76 - l) * var(--kitfx-n)) max(c, 0.07) calc(h + 60 * var(--kitfx-n)) / calc(0.26 + 0.08 * var(--kitfx-n)));
    --kitfx-a3: oklch(from var(--primary) calc(l + (0.88 - l) * 0.45) max(c * 0.75, 0.06) calc(h - 78 * var(--kitfx-n)) / 0.38);
  }
}
/* In dark mode the veils add light instead of laying a grey film over the page */
.dark .kitfx-aurora { mix-blend-mode: plus-lighter; }
`;

/* Peak strength per intensity, light and dark. Light stays a sheen, never a cloud. */
const INTENSITY: Record<AuroraIntensity, string> = {
  soft: 'opacity-[0.55] dark:opacity-[0.55]',
  medium: 'opacity-[0.8] dark:opacity-[0.78]',
  vivid: 'opacity-[0.9] dark:opacity-[0.88]',
};

interface Ribbon { at: number; h: number; w: number; x: number; r: number; tone: 'a1' | 'a2' | 'a3'; blur: number; t: number; y: number }

/* The veils, measured from the anchored edge (top, middle or bottom) */
const RIBBONS: Ribbon[] = [
  { at: 4, h: 34, w: 120, x: -14, r: -7, tone: 'a2', blur: 46, t: 1, y: 10 },
  { at: 16, h: 18, w: 96, x: -4, r: -4, tone: 'a1', blur: 30, t: 1.35, y: -8 },
  { at: 10, h: 26, w: 80, x: 34, r: 6, tone: 'a3', blur: 40, t: 1.7, y: 12 },
  { at: 26, h: 10, w: 70, x: 10, r: -3, tone: 'a1', blur: 18, t: 1.15, y: 6 },
];

/* Fine vertical rays, like the curtain of a real aurora */
const RAYS = 'repeating-linear-gradient(90deg, #000 0px, rgb(0 0 0 / 0.66) 13px, #000 27px, rgb(0 0 0 / 0.8) 38px, #000 50px, rgb(0 0 0 / 0.58) 61px, #000 74px)';

const EDGE: Record<AuroraPlacement, string> = {
  top: 'radial-gradient(ellipse 80% 90% at 50% 0%, #000 30%, transparent 78%)',
  center: 'radial-gradient(ellipse 72% 52% at 50% 50%, #000 40%, transparent 100%), radial-gradient(ellipse 34% 30% at 50% 50%, rgb(0 0 0 / 0.22), #000)',
  bottom: 'radial-gradient(ellipse 80% 90% at 50% 100%, #000 30%, transparent 78%)',
};

/**
 * Scroll pass. While `on`, writes --kitfx-p on the element: 0 when its top
 * meets the bottom of the window, 1 when its bottom leaves at the top. Never
 * with reduced motion, the CSS then falls back to the resting value.
 */
function useScrollPass(ref: { current: HTMLElement | null }, on: boolean) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
      el.style.setProperty('--kitfx-p', p.toFixed(4));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on]);
}

/**
 * Soft moving veils of light in the accent color. Fills its parent
 * (give the parent `relative isolate overflow-hidden`, content after it with
 * `relative`). Decorative, pauses out of view, still image with reduced motion.
 */
export function Aurora({ intensity = 'medium', placement = 'top', speed = 22, parallax = 36, className }: AuroraProps) {
  const ref = useRef<HTMLDivElement>(null);
  useScrollPass(ref, parallax !== 0);
  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => el.style.setProperty('--kitfx-play', e.isIntersecting ? 'running' : 'paused'));
    io.observe(el);
    return () => io.disconnect();
  }, []);
  const edge = EDGE[placement];
  return (
    <div
      ref={ref}
      aria-hidden
      data-clip-ok
      className={cn('kitfx-aurora pointer-events-none absolute inset-0 overflow-hidden', INTENSITY[intensity], placement === 'center' && 'max-sm:opacity-[0.6] max-sm:dark:opacity-[0.55]', className)}
      style={{ maskImage: edge, WebkitMaskImage: edge, maskComposite: 'intersect', WebkitMaskComposite: 'source-in' }}
    >
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      {/* The veils sit a little deeper than the page: they drift against the scroll */}
      <div className="kitfx-aurora-depth absolute inset-0" style={parallax ? { transform: `translate3d(0, calc((var(--kitfx-p, 0.5) - 0.5) * ${parallax}px), 0)` } : undefined}>
      <div className="absolute inset-x-[-10%] inset-y-0" style={{ maskImage: RAYS, WebkitMaskImage: RAYS, transform: 'skewX(-12deg)' }}>
        {RIBBONS.map((r, i) => {
          const at = placement === 'center' ? { top: `${18 + r.at * 1.3}%` } : placement === 'bottom' ? { bottom: `${r.at}%` } : { top: `${r.at}%` };
          return (
            <div
              key={i}
              className="absolute rounded-[50%]"
              style={{
                ...at,
                left: `${r.x}%`,
                width: `${r.w}%`,
                height: `${r.h}%`,
                background: `linear-gradient(90deg, transparent, var(--kitfx-${r.tone}) 28%, var(--kitfx-${r.tone}) 62%, transparent)`,
                filter: `blur(${r.blur}px)`,
                ['--kitfx-r' as string]: `${r.r}deg`,
                ['--kitfx-y' as string]: `${placement === 'bottom' ? -r.y : r.y}%`,
                transform: `rotate(${r.r}deg)`,
                animation: `kitfx-aurora-sway ${Math.round(speed * r.t)}s ease-in-out ${-i * 5}s infinite, kitfx-aurora-breathe ${Math.round(speed * r.t * 0.8)}s ease-in-out ${-i * 3}s infinite`,
              }}
            />
          );
        })}
      </div>
      </div>
    </div>
  );
}

Frequent questions

How do I install Aurora?

With aiw add @ai-website/aurora. The command puts components/ui/aurora.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Aurora have?

3 variants, Soft from the top, Vivid in the middle and Horizon.

Can an AI assistant build with Aurora?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Aurora with aiw_components and install it with aiw_add @ai-website/aurora.

Often searched

Jump to

Copied to the clipboard