# boatui > Prebuilt animated components for React: SVG scenes, textured backgrounds and three.js 3D scenes. Typed props and theme tokens; scenes and backgrounds have zero dependencies. ## Why use it Writing a comparable component from scratch costs 1,028–2,257 output tokens in a single pass before any visual iteration; importing one costs under 23. Estimates use four characters per token. | Component | Generate (tokens) | Import (tokens) | Ratio | | --- | --- | --- | --- | | `BoatScene` | 1,276 | 16 | 80× | | `LighthouseScene` | 1,668 | 19 | 88× | | `BalloonScene` | 1,730 | 17 | 102× | | `ReefScene` | 2,257 | 16 | 141× | | `TrainScene` | 1,585 | 16 | 99× | | `SnowfallBackground` | 1,028 | 21 | 49× | | `PetalsBackground` | 1,346 | 20 | 67× | | `FirefliesBackground` | 1,081 | 22 | 49× | | `CloudsBackground` | 1,051 | 20 | 53× | | `EmptyNap` | 1,188 | 17 | 70× | | `TinyPlanet3D` | 2,037 | 17 | 120× | | `Lagoon3D` | 2,227 | 15 | 148× | - Copy-paste React components: files are copied into the project, not installed from npm. MIT licensed. - Animation is CSS keyframes wherever possible; nothing runs a JavaScript animation loop unless the category says so. - Every component respects prefers-reduced-motion and has the same `theme`, `className`, `style` and `children` props as the rest of its category. - Requirements: React 18+ and CSS Modules (Next.js, Vite and most React setups support them). ## Scenes Scenes are portrait SVG illustrations (400×700 viewBox) animated with CSS keyframes only. They render on the server; a small client frame pauses them while off-screen. Every scene has the same props: `duration` (seconds for the main crossing), `theme` (partial colour overrides), `paused`, `className`, `style`, and `children` (rendered above the art, filling it). A scene fills its container width at a 4:7 aspect ratio. Theme keys map to CSS variables (`skyTop` → `--sky-top`, `water1` → `--water-1`); set variables through the scene's own `style`, not a parent. ```bash npx degit wi11s/boatui/components/scenes components/scenes ``` Shared files add ~1,622 tokens once. ### BoatScene > Sailboat crossing five drifting wave layers under a low sun. Front layers occlude the hull so it reads as floating. Ambient motion: bobbing, wake, clouds, sun glints. - Page: https://www.boatui.dev/scenes/boat - Source: https://github.com/wi11s/boatui/blob/main/components/scenes/boat-scene.tsx - Animated. Generate: ~1,276 tokens (source size, single pass). Import: ~16 tokens. #### Install All scenes: `npx degit wi11s/boatui/components/scenes components/scenes` Only this one: copy the shared files (`geometry.ts`, `scene.tsx`, `scene-frame.tsx`, `scene.module.css`) and `boat-scene.tsx` and `boat-scene.module.css` into `components/scenes/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { BoatScene } from '@/components/scenes/boat-scene'; export function Hero() { return (

Your content

); } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `duration` | `number` | 40 | Seconds for one left-to-right crossing. | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `paused` | `boolean` | false | Freezes all motion. Also paused automatically while off-screen. | | `className` | `string` | — | Applied to the root element. | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Rendered above the art in an absolutely positioned layer. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `skyTop` | `--sky-top` | `#9cc9e3` | | `skyBottom` | `--sky-bottom` | `#f3e6d3` | | `sun` | `--sun` | `#fff6dc` | | `water1` | `--water-1` | `#6fa9c2` | | `water2` | `--water-2` | `#4f92b2` | | `water3` | `--water-3` | `#3a7fa3` | | `water4` | `--water-4` | `#2a6a8d` | | `water5` | `--water-5` | `#1d5675` | | `hull` | `--hull` | `#b8322a` | #### Source `components/scenes/boat-scene.tsx` ```tsx import { W } from './geometry'; import { SceneFrame, WaveLayer, sceneStyles as base, useSvgId, type SceneProps, type WaveLayerProps } from './scene'; import styles from './boat-scene.module.css'; export const boatTheme = { skyTop: '#9cc9e3', skyBottom: '#f3e6d3', sun: '#fff6dc', water1: '#6fa9c2', water2: '#4f92b2', water3: '#3a7fa3', water4: '#2a6a8d', water5: '#1d5675', hull: '#b8322a', }; export type BoatTheme = typeof boatTheme; const BACK_WAVES: WaveLayerProps[] = [ { y: 382, components: [[2.5, 80]], fill: 'var(--water-1)', drift: 16, swell: 5.0, delay: 0 }, { y: 430, components: [[5, 100]], fill: 'var(--water-2)', drift: 12, swell: 4.2, delay: 0.9, reverse: true }, ]; const FRONT_WAVES: WaveLayerProps[] = [ { y: 482, components: [[7, W / 3]], fill: 'var(--water-3)', drift: 9, swell: 3.6, delay: 1.8 }, { y: 560, components: [[9, 200]], fill: 'var(--water-4)', drift: 8, swell: 4.6, delay: 2.7, reverse: true }, { y: 640, components: [[11, 200]], fill: 'var(--water-5)', drift: 6, swell: 3.9, delay: 3.6 }, ]; /** A small sailboat crossing gentle waves on a calm afternoon. */ export function BoatScene(props: SceneProps) { const sky = useSvgId('sky'); const glow = useSvgId('glow'); return ( {BACK_WAVES.map((w, i) => )} {/* Boat: origin is the waterline at mid-hull, bow pointing right */} {FRONT_WAVES.map((w, i) => )} } /> ); } ``` `components/scenes/boat-scene.module.css` ```css .boat { transform: translate(200px, 478px); /* resting spot for reduced motion */ animation: sail var(--duration) linear infinite; animation-delay: calc(var(--duration) * -0.2); /* start already in view */ } .bob { animation: bob 3.4s ease-in-out infinite alternate; } .wake { animation: wake 1.2s linear infinite; } @keyframes sail { from { transform: translate(-90px, 478px); } to { transform: translate(490px, 478px); } } @keyframes bob { 0% { transform: translateY(-2px) rotate(-2.5deg); } 50% { transform: translateY(2px) rotate(1deg); } 100% { transform: translateY(-1px) rotate(3deg); } } @keyframes wake { to { stroke-dashoffset: 18; } } ``` ### LighthouseScene > Lighthouse on a headland with a sweeping beam that flashes when facing the viewer. A steamer crosses the horizon. Ambient motion: twinkling stars, moon glints, drifting waves. - Page: https://www.boatui.dev/scenes/lighthouse - Source: https://github.com/wi11s/boatui/blob/main/components/scenes/lighthouse-scene.tsx - Animated. Generate: ~1,668 tokens (source size, single pass). Import: ~19 tokens. #### Install All scenes: `npx degit wi11s/boatui/components/scenes components/scenes` Only this one: copy the shared files (`geometry.ts`, `scene.tsx`, `scene-frame.tsx`, `scene.module.css`) and `lighthouse-scene.tsx` and `lighthouse-scene.module.css` into `components/scenes/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { LighthouseScene } from '@/components/scenes/lighthouse-scene'; export function Hero() { return (

Your content

); } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `duration` | `number` | 80 | Seconds for the steamer's crossing. | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `paused` | `boolean` | false | Freezes all motion. Also paused automatically while off-screen. | | `className` | `string` | — | Applied to the root element. | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Rendered above the art in an absolutely positioned layer. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `skyTop` | `--sky-top` | `#0a1430` | | `skyBottom` | `--sky-bottom` | `#2b3f6e` | | `moon` | `--moon` | `#f3edd8` | | `beam` | `--beam` | `#fff1c4` | | `land` | `--land` | `#0d162d` | | `water1` | `--water-1` | `#26395f` | | `water2` | `--water-2` | `#1b2b50` | | `water3` | `--water-3` | `#132142` | | `water4` | `--water-4` | `#0c1733` | #### Source `components/scenes/lighthouse-scene.tsx` ```tsx import { W, seeded } from './geometry'; import { SceneFrame, WaveLayer, sceneStyles as base, useSvgId, type SceneProps, type WaveLayerProps } from './scene'; import styles from './lighthouse-scene.module.css'; export const lighthouseTheme = { skyTop: '#0a1430', skyBottom: '#2b3f6e', moon: '#f3edd8', beam: '#fff1c4', land: '#0d162d', water1: '#26395f', water2: '#1b2b50', water3: '#132142', water4: '#0c1733', }; export type LighthouseTheme = typeof lighthouseTheme; const HORIZON = 432; const LAMP = { x: 318, y: 283 }; // Starfield, kept above the horizon and clear of the moon. const rand = seeded(7); const STARS = Array.from({ length: 48 }, () => { const x = rand() * W, y = 20 + rand() * 360; return { x, y, r: 0.5 + rand() * 1.2, dur: 2 + rand() * 3, delay: rand() * 5 }; }).filter(s => Math.hypot(s.x - 96, s.y - 150) > 40); // Tower tapers from half-width 15 at the base (y 404) to 10 at the gallery (y 300). const halfWidth = (y: number) => 10 + (5 * (y - 300)) / 104; const towerBand = (y1: number, y2: number) => `M${LAMP.x - halfWidth(y1)} ${y1} L${LAMP.x + halfWidth(y1)} ${y1} ` + `L${LAMP.x + halfWidth(y2)} ${y2} L${LAMP.x - halfWidth(y2)} ${y2} Z`; const FRONT_WAVES: WaveLayerProps[] = [ { y: 470, components: [[4, 100]], fill: 'var(--water-2)', drift: 13, swell: 4.4, delay: 1, reverse: true }, { y: 540, components: [[7, W / 3]], fill: 'var(--water-3)', drift: 10, swell: 3.8, delay: 2 }, { y: 620, components: [[10, 200]], fill: 'var(--water-4)', drift: 7, swell: 4.6, delay: 3, reverse: true }, ]; /** A lighthouse sweeping its beam over a moonlit sea while a distant steamer crosses the horizon. */ export function LighthouseScene(props: SceneProps) { const sky = useSvgId('sky'); const moonGlow = useSvgId('moonglow'); const beam = useSvgId('beam'); const lampGlow = useSvgId('lampglow'); return ( {STARS.map((s, i) => ( ))} {/* Distant steamer: origin at its waterline */} {/* Headland, keeper's cottage and lighthouse */} {FRONT_WAVES.map((w, i) => )} } /> ); } ``` `components/scenes/lighthouse-scene.module.css` ```css .star { animation: twinkle ease-in-out infinite alternate; } @keyframes twinkle { from { opacity: 0.15; } to { opacity: 1; } } .ship { transform: translate(140px, 432px); animation: steam var(--duration) linear infinite; animation-delay: calc(var(--duration) * -0.25); } @keyframes steam { from { transform: translate(-60px, 432px); } to { transform: translate(460px, 432px); } } /* Squashing the beam through zero reads as a rotating light; the lamp flashes as it faces us. */ .beam { animation: sweep 10s ease-in-out infinite; } .lamp { animation: flash 5s ease-in-out infinite; } @keyframes sweep { 0%, 100% { transform: scaleX(1); } 50% { transform: scaleX(-1); } } @keyframes flash { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } } ``` ### BalloonScene > Hot-air balloon ascending diagonally over four hill layers at sunrise. Ambient motion: swaying basket, burner flicker, drifting mist, a distant second balloon, birds. - Page: https://www.boatui.dev/scenes/balloon - Source: https://github.com/wi11s/boatui/blob/main/components/scenes/balloon-scene.tsx - Animated. Generate: ~1,730 tokens (source size, single pass). Import: ~17 tokens. #### Install All scenes: `npx degit wi11s/boatui/components/scenes components/scenes` Only this one: copy the shared files (`geometry.ts`, `scene.tsx`, `scene-frame.tsx`, `scene.module.css`) and `balloon-scene.tsx` and `balloon-scene.module.css` into `components/scenes/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { BalloonScene } from '@/components/scenes/balloon-scene'; export function Hero() { return (

Your content

); } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `duration` | `number` | 45 | Seconds for the balloon's ascent. | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `paused` | `boolean` | false | Freezes all motion. Also paused automatically while off-screen. | | `className` | `string` | — | Applied to the root element. | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Rendered above the art in an absolutely positioned layer. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `skyTop` | `--sky-top` | `#a9b8d9` | | `skyMid` | `--sky-mid` | `#f6c6b0` | | `skyBottom` | `--sky-bottom` | `#fde3c0` | | `sun` | `--sun` | `#fff1d4` | | `hill1` | `--hill-1` | `#c9a1b1` | | `hill2` | `--hill-2` | `#a383a0` | | `hill3` | `--hill-3` | `#6e7f7a` | | `hill4` | `--hill-4` | `#4d6656` | | `envelope` | `--envelope` | `#e4573d` | | `stripe` | `--stripe` | `#f3c64f` | #### Source `components/scenes/balloon-scene.tsx` ```tsx import { W, bandPath, ridgeY, seeded, type Ridge } from './geometry'; import { SceneFrame, sceneStyles as base, useSvgId, type SceneProps } from './scene'; import styles from './balloon-scene.module.css'; export const balloonTheme = { skyTop: '#a9b8d9', skyMid: '#f6c6b0', skyBottom: '#fde3c0', sun: '#fff1d4', hill1: '#c9a1b1', hill2: '#a383a0', hill3: '#6e7f7a', hill4: '#4d6656', envelope: '#e4573d', stripe: '#f3c64f', }; export type BalloonTheme = typeof balloonTheme; const HILLS: { y: number; components: Ridge; fill: string }[] = [ { y: 450, components: [[14, 400], [6, 200, 1]], fill: 'var(--hill-1)' }, { y: 505, components: [[20, 400, 2], [8, 200]], fill: 'var(--hill-2)' }, { y: 575, components: [[24, 400, 4], [9, 200, 1]], fill: 'var(--hill-3)' }, { y: 645, components: [[20, 400, 1], [10, 100]], fill: 'var(--hill-4)' }, ]; const HILL_PATHS = HILLS.map(h => bandPath(h.y, h.components)); // Trees scattered along the crest of the third hill. const rand = seeded(11); const TREES = Array.from({ length: 11 }, () => { const x = rand() * W; return { x, y: ridgeY(x, HILLS[2].y, HILLS[2].components) + 6, s: 0.7 + rand() * 0.6 }; }); function Balloon({ envelope, stripe }: { envelope: string; stripe: string }) { return ( <> ); } const BIRDS = [ { x: 0, y: 0, delay: 0, d: 'M-6 0 Q-3 -3 0 0 Q3 -3 6 0' }, { x: 16, y: 8, delay: -0.2, d: 'M-5 0 Q-2.5 -2.5 0 0 Q2.5 -2.5 5 0' }, { x: 30, y: -4, delay: -0.35, d: 'M-5 0 Q-2.5 -2.5 0 0 Q2.5 -2.5 5 0' }, ]; /** A hot-air balloon drifting up and across rolling hills at first light. */ export function BalloonScene(props: SceneProps) { const sky = useSvgId('sky'); const glow = useSvgId('glow'); return ( {/* A second balloon far off, just bobbing */} {BIRDS.map((b, i) => ( ))} {TREES.map((t, i) => ( ))} {/* Main balloon: origin at the middle of the envelope */} } /> ); } ``` `components/scenes/balloon-scene.module.css` ```css .balloon { transform: translate(200px, 330px); animation: rise var(--duration) linear infinite; animation-delay: calc(var(--duration) * -0.3); } .sway { animation: sway 5s ease-in-out infinite alternate; } .flame { animation: flicker 0.35s ease-in-out infinite alternate; } .float { animation: float 6s ease-in-out infinite alternate; } .flock { transform: translate(260px, 190px); animation: flock 34s linear infinite; animation-delay: -12s; } .flap { animation: flap 0.5s ease-in-out infinite alternate; } @keyframes rise { from { transform: translate(-70px, 600px); } to { transform: translate(470px, 140px); } } @keyframes sway { from { transform: rotate(-3deg) translateY(-2px); } to { transform: rotate(3deg) translateY(2px); } } @keyframes flicker { from { opacity: 0.45; } to { opacity: 1; } } @keyframes float { from { transform: translateY(-6px); } to { transform: translateY(6px); } } @keyframes flock { from { transform: translate(440px, 210px); } to { transform: translate(-60px, 170px); } } @keyframes flap { from { transform: scaleY(1); } to { transform: scaleY(-0.4); } } ``` ### ReefScene > Sea turtle swimming across a shallow reef. A fish school crosses the other way. Ambient motion: surface ripples, light rays, swaying kelp, rising bubbles. - Page: https://www.boatui.dev/scenes/reef - Source: https://github.com/wi11s/boatui/blob/main/components/scenes/reef-scene.tsx - Animated. Generate: ~2,257 tokens (source size, single pass). Import: ~16 tokens. #### Install All scenes: `npx degit wi11s/boatui/components/scenes components/scenes` Only this one: copy the shared files (`geometry.ts`, `scene.tsx`, `scene-frame.tsx`, `scene.module.css`) and `reef-scene.tsx` and `reef-scene.module.css` into `components/scenes/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { ReefScene } from '@/components/scenes/reef-scene'; export function Hero() { return (

Your content

); } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `duration` | `number` | 50 | Seconds for the turtle's crossing. | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `paused` | `boolean` | false | Freezes all motion. Also paused automatically while off-screen. | | `className` | `string` | — | Applied to the root element. | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Rendered above the art in an absolutely positioned layer. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `waterTop` | `--water-top` | `#5fd0d4` | | `waterMid` | `--water-mid` | `#1f8aa6` | | `waterDeep` | `--water-deep` | `#0b3b5a` | | `sand1` | `--sand-1` | `#c4ab74` | | `sand2` | `--sand-2` | `#a99062` | | `kelp` | `--kelp` | `#2f7d52` | | `fish` | `--fish` | `#f2c14e` | #### Source `components/scenes/reef-scene.tsx` ```tsx import { W, bandPath, ridgeY, seeded, type Ridge } from './geometry'; import { SceneFrame, WaveLayer, sceneStyles as base, useSvgId, type SceneProps } from './scene'; import styles from './reef-scene.module.css'; export const reefTheme = { waterTop: '#5fd0d4', waterMid: '#1f8aa6', waterDeep: '#0b3b5a', sand1: '#c4ab74', sand2: '#a99062', kelp: '#2f7d52', fish: '#f2c14e', }; export type ReefTheme = typeof reefTheme; const SAND_BACK: { y: number; components: Ridge } = { y: 615, components: [[10, 400, 2], [4, 100]] }; const SAND_FRONT: { y: number; components: Ridge } = { y: 662, components: [[8, 200, 1], [3, 80]] }; const PATHS = { ridgeFar: bandPath(520, [[14, 400], [6, 200, 2]]), ridgeNear: bandPath(560, [[16, 400, 1], [7, 100]]), sandBack: bandPath(SAND_BACK.y, SAND_BACK.components), sandFront: bandPath(SAND_FRONT.y, SAND_FRONT.components), }; const rand = seeded(23); const BUBBLES = Array.from({ length: 12 }, () => ({ x: 20 + rand() * (W - 40), r: 1.5 + rand() * 3, dur: 8 + rand() * 8, delay: rand() * 16, })); const KELP = [ { x: 34, h: 300, dur: 5.5 }, { x: 60, h: 220, dur: 4.4 }, { x: 214, h: 150, dur: 6.2 }, { x: 352, h: 280, dur: 5.0 }, { x: 378, h: 200, dur: 4.1 }, ].map(k => { const leaves: { cx: number; cy: number; angle: number }[] = []; for (let y = 40, side = 1; y < k.h - 10; y += 34, side = -side) { leaves.push({ cx: side * 7, cy: -y, angle: side * -30 }); } return { ...k, leaves }; }); const CORAL = [ { x: 122, colors: ['#e8846f', '#f2a65a', '#d96a8b'] }, { x: 268, colors: ['#d96a8b', '#e8846f', '#f2c14e'] }, ].map(c => ({ ...c, y: ridgeY(c.x, SAND_BACK.y, SAND_BACK.components) })); const FISH = [[0, 0], [22, -10], [26, 12], [46, 2], [50, -16], [68, 10], [12, 22]]; const RAYS = [ { d: 'M70 0 L112 0 L210 700 L140 700 Z', dur: 6, delay: 0 }, { d: 'M170 0 L196 0 L290 700 L246 700 Z', dur: 8, delay: -3 }, { d: 'M250 0 L300 0 L410 700 L330 700 Z', dur: 7, delay: -5 }, { d: 'M10 0 L30 0 L90 700 L50 700 Z', dur: 9, delay: -1 }, ]; /** A sea turtle gliding across a sunlit reef, with swaying kelp, rising bubbles and a school of fish. */ export function ReefScene(props: SceneProps) { const water = useSvgId('water'); const ray = useSvgId('ray'); return ( {/* Surface seen from below */} {RAYS.map((r, i) => ( ))} {/* Turtle: origin at mid-shell, facing right */} {/* School of fish heading right-to-left */} {FISH.map(([x, y], i) => ( ))} {CORAL.map((c, i) => ( ))} {KELP.map((k, i) => ( {k.leaves.map((l, j) => ( ))} ))} {BUBBLES.map((b, i) => ( ))} } /> ); } ``` `components/scenes/reef-scene.module.css` ```css .turtle { transform: translate(200px, 320px); animation: cruise var(--duration) linear infinite; animation-delay: calc(var(--duration) * -0.25); } .glide { animation: glide 4s ease-in-out infinite alternate; } .paddle { animation: paddle 2.4s ease-in-out infinite alternate; } .school { transform: translate(260px, 470px); animation: school 24s linear infinite; animation-delay: -6s; } .ray { animation: ray ease-in-out infinite alternate; } .sway { animation: sway ease-in-out infinite alternate; } .rise { animation: rise linear infinite; } .wobble { animation: wobble 1.6s ease-in-out infinite alternate; } @keyframes cruise { from { transform: translate(-90px, 340px); } to { transform: translate(490px, 300px); } } @keyframes glide { from { transform: translateY(-5px) rotate(-2deg); } to { transform: translateY(5px) rotate(2deg); } } @keyframes paddle { from { transform: rotate(-20deg); } to { transform: rotate(24deg); } } @keyframes school { from { transform: translate(470px, 480px); } to { transform: translate(-130px, 455px); } } @keyframes ray { from { opacity: 0.3; } to { opacity: 1; } } @keyframes sway { from { transform: rotate(-5deg); } to { transform: rotate(5deg); } } @keyframes rise { 0% { transform: translateY(0); opacity: 0; } 10% { opacity: 0.8; } 85% { opacity: 0.6; } 100% { transform: translateY(-680px); opacity: 0; } } @keyframes wobble { from { transform: translateX(-3px); } to { transform: translateX(3px); } } ``` ### TrainScene > Steam train crossing a stone viaduct at dusk with mountains and a setting sun behind. Ambient motion: steam puffs, twinkling stars, drifting valley mist. - Page: https://www.boatui.dev/scenes/train - Source: https://github.com/wi11s/boatui/blob/main/components/scenes/train-scene.tsx - Animated. Generate: ~1,585 tokens (source size, single pass). Import: ~16 tokens. #### Install All scenes: `npx degit wi11s/boatui/components/scenes components/scenes` Only this one: copy the shared files (`geometry.ts`, `scene.tsx`, `scene-frame.tsx`, `scene.module.css`) and `train-scene.tsx` and `train-scene.module.css` into `components/scenes/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { TrainScene } from '@/components/scenes/train-scene'; export function Hero() { return (

Your content

); } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `duration` | `number` | 30 | Seconds for the train crossing the frame. | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `paused` | `boolean` | false | Freezes all motion. Also paused automatically while off-screen. | | `className` | `string` | — | Applied to the root element. | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Rendered above the art in an absolutely positioned layer. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `skyTop` | `--sky-top` | `#283a6b` | | `skyMid` | `--sky-mid` | `#b5647f` | | `skyBottom` | `--sky-bottom` | `#f4a76f` | | `sun` | `--sun` | `#ffd9a0` | | `mountain1` | `--mountain-1` | `#7a5a86` | | `mountain2` | `--mountain-2` | `#563f6b` | | `viaduct` | `--viaduct` | `#3a2b4f` | | `forest` | `--forest` | `#241b38` | | `train` | `--train` | `#1b1428` | | `windows` | `--windows` | `#ffd98a` | #### Source `components/scenes/train-scene.tsx` ```tsx // : a steam train crossing a stone viaduct at dusk, mountains behind. // `duration` is seconds for the train to cross the frame (default 30). import { W, bandPath, ridgeY, seeded, type Ridge } from './geometry'; import { SceneFrame, sceneStyles as base, useSvgId, type SceneProps } from './scene'; import styles from './train-scene.module.css'; export const trainTheme = { skyTop: '#283a6b', skyMid: '#b5647f', skyBottom: '#f4a76f', sun: '#ffd9a0', mountain1: '#7a5a86', mountain2: '#563f6b', viaduct: '#3a2b4f', forest: '#241b38', train: '#1b1428', windows: '#ffd98a', }; export type TrainTheme = typeof trainTheme; const DECK = 470; const FOREST: Ridge = [[10, 200], [6, 80]]; const rand = seeded(31); const STARS = Array.from({ length: 30 }, () => ({ x: rand() * W, y: 20 + rand() * 230, r: 0.5 + rand() * 1, dur: 2 + rand() * 3, delay: rand() * 5, })); // Trees along the forest ridge in front of the viaduct's piers. const TREES = Array.from({ length: 16 }, (_, i) => { const x = i * 26 + rand() * 12; return { x, y: ridgeY(x, 640, FOREST) + 2, h: 26 + rand() * 22 }; }); // Viaduct: a solid wall with arched openings cut out (even-odd fill). const VIADUCT = (() => { let d = `M-10 ${DECK} H${W + 10} V700 H-10 Z`; for (let x = 0; x < W; x += 50) { const left = x + 12, right = x + 50, r = (right - left) / 2; d += ` M${left} 700 V520 A${r} ${r} 0 0 1 ${right} 520 V700 Z`; } return d; })(); const CARRIAGES = [0, 1, 2].map(k => -110 - 50 * k); /** A steam train crossing a stone viaduct at dusk. */ export function TrainScene(props: SceneProps) { const sky = useSvgId('sky'); const glow = useSvgId('glow'); return ( {STARS.map((s, i) => ( ))} {/* Train: origin at the front of the locomotive, on the deck, heading right */} {CARRIAGES.map(x => ( ))} {CARRIAGES.flatMap(x => [0, 1, 2, 3].map(j => ), )} {[-50, -36, -20, -8].map(x => )} {CARRIAGES.flatMap(x => [x + 8, x + 38]).map(x => )} {[0, 0.8, 1.6].map(d => ( ))} {TREES.map((t, i) => ( ))} } /> ); } ``` `components/scenes/train-scene.module.css` ```css .train { transform: translate(300px, 470px); /* resting spot for reduced motion */ animation: run var(--duration) linear infinite; animation-delay: calc(var(--duration) * -0.45); } .puff { animation: puff 2.4s ease-out infinite; } .star { animation: twinkle ease-in-out infinite alternate; } .glow { animation: glow 3s ease-in-out infinite alternate; } @keyframes run { from { transform: translate(0px, 470px); } to { transform: translate(620px, 470px); } } @keyframes puff { 0% { transform: translate(0, 0) scale(0.6); opacity: 0.85; } 100% { transform: translate(-48px, -40px) scale(2.6); opacity: 0; } } @keyframes twinkle { from { opacity: 0.2; } to { opacity: 1; } } @keyframes glow { from { opacity: 0.75; } to { opacity: 1; } } ``` ### Shared scenes files `components/scenes/geometry.ts` ```tsx // Shared geometry for the scenes. Every scene draws into a portrait 400×700 viewBox. export const W = 400; export const H = 700; /** One sine term of a ridge: [amplitude, wavelength, phase?]. */ export type Ridge = ReadonlyArray; /** Height of a ridge made of summed sines at x. */ export function ridgeY(x: number, y: number, components: Ridge): number { let h = y; for (const [amp, len, phase = 0] of components) h += amp * Math.sin((x / len) * Math.PI * 2 + phase); return h; } /** * A closed band 2×W wide whose edge follows ridgeY, filled toward `toward` (H = down, 0 = up). * Sliding it by W loops seamlessly as long as every wavelength divides W evenly. */ export function bandPath(y: number, components: Ridge, toward = H): string { let d = `M0 ${toward}`; for (let x = 0; x <= W * 2; x += 8) d += ` L${x} ${ridgeY(x, y, components).toFixed(1)}`; return `${d} L${W * 2} ${toward} Z`; } /** Deterministic PRNG so scattered details (stars, trees, bubbles) render identically on server and client. */ export function seeded(seed: number): () => number { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } ``` `components/scenes/scene.tsx` ```tsx import { useId, type CSSProperties, type ReactNode } from 'react'; import { H, bandPath, type Ridge } from './geometry'; import styles from './scene.module.css'; export { SceneFrame } from './scene-frame'; export { styles as sceneStyles }; /** Props every scene accepts. `T` is the scene's theme (colour tokens). */ export type SceneProps = { /** Seconds for the scene's main crossing. */ duration?: number; /** Override any of the scene's colours. */ theme?: Partial; /** Freeze the animation. Scenes also pause on their own while off-screen. */ paused?: boolean; className?: string; style?: CSSProperties; /** Content rendered on top of the scene (headings, buttons…). */ children?: ReactNode; }; /** A per-instance prefix for gradient ids, so several copies of a scene can share a page. */ export function useSvgId(name: string): string { return `qs${useId().replace(/[^a-zA-Z0-9_-]/g, '')}${name}`; } export type WaveLayerProps = { y: number; components: Ridge; fill: string; /** Seconds per sideways loop; 0 keeps the band still. */ drift?: number; /** Seconds per up-and-down swell; 0 disables it. */ swell?: number; reverse?: boolean; delay?: number; /** Fill toward the bottom (H) or the top (0) of the scene. */ toward?: number; opacity?: number; }; /** A band of water, hills or sand that can drift sideways and swell. */ export function WaveLayer({ y, components, fill, drift = 0, swell = 0, reverse, delay = 0, toward = H, opacity }: WaveLayerProps) { const path = ( ); if (!swell) return path; return ( {path} ); } ``` `components/scenes/scene-frame.tsx` ```tsx 'use client'; import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { H, W } from './geometry'; import styles from './scene.module.css'; // skyTop → --sky-top, water1 → --water-1 const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`; export type SceneFrameProps = { defaultTheme: Record; defaultDuration: number; art: ReactNode; duration?: number; theme?: Record; paused?: boolean; className?: string; style?: CSSProperties; children?: ReactNode; }; /** * The frame shared by every scene: sizing, theming, overlay and off-screen pausing. * It's the only client component; the scene art itself renders on the server. */ export function SceneFrame({ defaultTheme, defaultDuration, art, duration = defaultDuration, theme, paused, className, style, children, }: SceneFrameProps) { const ref = useRef(null); const [offscreen, setOffscreen] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const observer = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting)); observer.observe(el); return () => observer.disconnect(); }, []); const vars: Record = { '--duration': `${duration}s` }; for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) { if (value) vars[toVar(key)] = value; } const classes = [styles.root, (paused || offscreen) && styles.paused, className].filter(Boolean).join(' '); return (
{children &&
{children}
}
); } ``` `components/scenes/scene.module.css` ```css .root { position: relative; display: block; width: 100%; aspect-ratio: 4 / 7; overflow: hidden; contain: content; } .art { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } .overlay { position: absolute; inset: 0; } /* Shared motion primitives used by every scene */ .drift { animation: drift linear infinite; } .reverse { animation-name: driftReverse; } .swell { animation: swell ease-in-out infinite alternate; } .across { animation: across linear infinite; } .glint { animation: glint ease-in-out infinite alternate; } @keyframes drift { to { transform: translateX(-400px); } } @keyframes driftReverse { from { transform: translateX(-400px); } to { transform: translateX(0); } } @keyframes swell { from { transform: translateY(-3px); } to { transform: translateY(3px); } } @keyframes across { from { transform: translateX(-260px); } to { transform: translateX(660px); } } @keyframes glint { from { opacity: 0.25; } to { opacity: 0.85; } } .paused * { animation-play-state: paused !important; } @media (prefers-reduced-motion: reduce) { .root * { animation: none !important; } } ``` ## Backgrounds Backgrounds are textures that paint behind your content: wrap content in the component, e.g. ``. They are pure server components with no client JavaScript; particles move with CSS keyframes and container units, so they fill any size. Every background has the same props: `theme`, `paused`, `className`, `style` and `children`. The root is a block element with `position: relative`; size it like any div (for a full page, `min-height: 100vh`). ```bash npx degit wi11s/boatui/components/backgrounds components/backgrounds ``` Shared files add ~745 tokens once. ### SnowfallBackground > Snow falling at three depths over a pale winter sky: small slow flakes far away, six-armed spinning flakes up close, all swaying as they fall onto soft drifts at the bottom. About 75 flakes, CSS keyframes only, sized to any box with container units. - Page: https://www.boatui.dev/backgrounds/snowfall - Source: https://github.com/wi11s/boatui/blob/main/components/backgrounds/snowfall-background.tsx - Animated. Generate: ~1,028 tokens (source size, single pass). Import: ~21 tokens. #### Install All backgrounds: `npx degit wi11s/boatui/components/backgrounds components/backgrounds` Only this one: copy the shared files (`background.tsx`, `background.module.css`) and `snowfall-background.tsx` and `snowfall-background.module.css` into `components/backgrounds/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { SnowfallBackground } from '@/components/backgrounds/snowfall-background'; export default function Layout({ children }: { children: React.ReactNode }) { return {children}; } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `paused` | `boolean` | false | Freezes the animation. | | `className` | `string` | — | Applied to the root element. Size it like any block element. | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Your content. The background paints behind it. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `skyTop` | `--sky-top` | `#bcd3ee` | | `skyBottom` | `--sky-bottom` | `#eef4fb` | | `flake` | `--flake` | `#ffffff` | | `drift` | `--drift` | `#ffffff` | | `driftShadow` | `--drift-shadow` | `#d9e6f4` | #### Source `components/backgrounds/snowfall-background.tsx` ```tsx // : snow falling at three depths onto soft drifts. Animated. // Far flakes are small and slow, near flakes are six-armed and spin as they sway. import type { CSSProperties } from 'react'; import { BackgroundFrame, backgroundStyles as base, r2, seeded, type BackgroundProps } from './background'; import styles from './snowfall-background.module.css'; export const snowfallTheme = { skyTop: '#bcd3ee', skyBottom: '#eef4fb', flake: '#ffffff', drift: '#ffffff', driftShadow: '#d9e6f4', }; export type SnowfallTheme = typeof snowfallTheme; const LAYERS = [ { count: 40, size: [2, 3.5], fall: [18, 26], sway: [6, 14], opacity: 0.6 }, { count: 24, size: [4, 6], fall: [12, 17], sway: [12, 24], opacity: 0.85 }, { count: 12, size: [12, 18], fall: [8, 12], sway: [18, 34], opacity: 1 }, ]; const rand = seeded(41); const between = ([a, b]: number[]) => a + rand() * (b - a); const FLAKES = LAYERS.flatMap((layer, depth) => Array.from({ length: layer.count }, () => ({ depth, x: r2(rand() * 100), y: r2(rand() * 100), size: r2(between(layer.size)), fall: r2(between(layer.fall)), delay: r2(rand() * 30), sway: r2(between(layer.sway)), swayTime: r2(2 + rand() * 3), spin: r2(6 + rand() * 8), opacity: layer.opacity, })), ); // Six arms, each with a small V near the tip. const ARM = 'M0 0 V-9 M0 -6 L-2.5 -8.5 M0 -6 L2.5 -8.5'; function Flake({ size }: { size: number }) { return ( {[0, 60, 120, 180, 240, 300].map(a => )} ); } /** Snow falling at three depths onto soft drifts. */ export function SnowfallBackground(props: BackgroundProps) { return (
{FLAKES.map((f, i) => (
{f.depth === 2 ? ( ) : ( )}
))} } /> ); } ``` `components/backgrounds/snowfall-background.module.css` ```css .sky { position: absolute; inset: 0; background: linear-gradient(var(--sky-top), var(--sky-bottom)); } .fall { animation: fall linear infinite; will-change: transform; } .sway { animation: sway ease-in-out infinite alternate; } .spin { animation: spin linear infinite; display: block; } .dot { display: block; border-radius: 50%; background: var(--flake); box-shadow: 0 0 2px rgb(90 120 160 / 0.25); } .flake { display: block; stroke: var(--flake); filter: drop-shadow(0 0 1px rgb(90 120 160 / 0.35)); } .drifts { position: absolute; left: 0; bottom: 0; width: 100%; height: 72px; } @keyframes fall { from { transform: translateY(-24px); } to { transform: translateY(calc(100cqh + 24px)); } } @keyframes sway { from { transform: translateX(calc(var(--sway) * -1)); } to { transform: translateX(var(--sway)); } } @keyframes spin { to { transform: rotate(360deg); } } ``` ### PetalsBackground > Cherry-blossom petals drifting down and left on a breeze, each spinning and fluttering (a 3D flip faked with scaleX), over soft bokeh light and a blossoming branch that sways from the top-right corner. 36 petals, CSS keyframes only. - Page: https://www.boatui.dev/backgrounds/petals - Source: https://github.com/wi11s/boatui/blob/main/components/backgrounds/petals-background.tsx - Animated. Generate: ~1,346 tokens (source size, single pass). Import: ~20 tokens. #### Install All backgrounds: `npx degit wi11s/boatui/components/backgrounds components/backgrounds` Only this one: copy the shared files (`background.tsx`, `background.module.css`) and `petals-background.tsx` and `petals-background.module.css` into `components/backgrounds/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { PetalsBackground } from '@/components/backgrounds/petals-background'; export default function Layout({ children }: { children: React.ReactNode }) { return {children}; } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `paused` | `boolean` | false | Freezes the animation. | | `className` | `string` | — | Applied to the root element. Size it like any block element. | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Your content. The background paints behind it. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `baseTop` | `--base-top` | `#fff7f3` | | `baseBottom` | `--base-bottom` | `#fde3ea` | | `petal1` | `--petal-1` | `#f6a9be` | | `petal2` | `--petal-2` | `#fbcfdc` | | `glow` | `--glow` | `#ffffff` | | `branch` | `--branch` | `#8a5a52` | | `blossom` | `--blossom` | `#f8bed0` | #### Source `components/backgrounds/petals-background.tsx` ```tsx // : cherry-blossom petals drifting on a breeze, with a swaying branch // in the corner and soft bokeh. Animated. import type { CSSProperties } from 'react'; import { BackgroundFrame, backgroundStyles as base, r2, seeded, type BackgroundProps } from './background'; import styles from './petals-background.module.css'; export const petalsTheme = { baseTop: '#fff7f3', baseBottom: '#fde3ea', petal1: '#f6a9be', petal2: '#fbcfdc', glow: '#ffffff', branch: '#8a5a52', blossom: '#f8bed0', }; export type PetalsTheme = typeof petalsTheme; // Heart-shaped petal with a notch at the tip. const PETAL = 'M0 8 C-6 4 -7 -3 -3 -7 C-1.5 -8.5 -0.5 -7.5 0 -6 C0.5 -7.5 1.5 -8.5 3 -7 C7 -3 6 4 0 8 Z'; const rand = seeded(57); const PETALS = Array.from({ length: 36 }, (_, i) => ({ // Start up to 25% past the right edge so the breeze carries petals across all of it. x: r2(rand() * 125), y: r2(rand() * 100), size: r2(10 + rand() * 9), fall: r2(11 + rand() * 9), delay: r2(rand() * 20), spin: r2(5 + rand() * 7) * (rand() > 0.5 ? 1 : -1), flutter: r2(0.8 + rand() * 1.2), color: i % 3 === 0 ? 'var(--petal-1)' : 'var(--petal-2)', })); const BOKEH = Array.from({ length: 7 }, () => ({ x: r2(rand() * 90), y: r2(rand() * 85), size: r2(60 + rand() * 90), dur: r2(8 + rand() * 8), delay: r2(rand() * 8), })); function Blossom({ x, y, r }: { x: number; y: number; r: number }) { return ( {[0, 72, 144, 216, 288].map(a => ( ))} ); } /** Cherry-blossom petals drifting on a breeze. */ export function PetalsBackground(props: BackgroundProps) { return (
{BOKEH.map((b, i) => (
))} {PETALS.map((p, i) => (
))} } /> ); } ``` `components/backgrounds/petals-background.module.css` ```css .base { position: absolute; inset: 0; background: linear-gradient(170deg, var(--base-top), var(--base-bottom)); } /* Petals ride a breeze: down the full height and a fifth of the width to the left. */ .fall { animation: fall linear infinite; will-change: transform; } .spin { display: block; animation: spin linear infinite; } .flutter { display: block; animation: flutter ease-in-out infinite alternate; } .bokeh { position: absolute; border-radius: 50%; background: radial-gradient(circle, var(--glow) 0%, transparent 70%); opacity: 0.55; animation: float ease-in-out infinite alternate; } /* The branch hangs from the top-right corner and sways from its base. */ .branch { position: absolute; top: -6px; right: -10px; width: 230px; transform-origin: 100% 0; animation: sway 7s ease-in-out infinite alternate; } @keyframes fall { from { transform: translate(0, -30px); } to { transform: translate(-20cqw, calc(100cqh + 30px)); } } @keyframes spin { to { transform: rotate(360deg); } } @keyframes flutter { from { transform: scaleX(1); } to { transform: scaleX(0.25); } } @keyframes float { from { transform: translate(0, 0); } to { transform: translate(24px, -18px); } } @keyframes sway { from { transform: rotate(-2deg); } to { transform: rotate(1.5deg); } } ``` ### FirefliesBackground > Thirty fireflies wandering four-point loops and blinking on their own rhythms over a dusk gradient, with a crescent moon and a meadow of swaying grass blades placed by percentage so it spans any width. CSS keyframes only. - Page: https://www.boatui.dev/backgrounds/fireflies - Source: https://github.com/wi11s/boatui/blob/main/components/backgrounds/fireflies-background.tsx - Animated. Generate: ~1,081 tokens (source size, single pass). Import: ~22 tokens. #### Install All backgrounds: `npx degit wi11s/boatui/components/backgrounds components/backgrounds` Only this one: copy the shared files (`background.tsx`, `background.module.css`) and `fireflies-background.tsx` and `fireflies-background.module.css` into `components/backgrounds/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { FirefliesBackground } from '@/components/backgrounds/fireflies-background'; export default function Layout({ children }: { children: React.ReactNode }) { return {children}; } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `paused` | `boolean` | false | Freezes the animation. | | `className` | `string` | — | Applied to the root element. Size it like any block element. | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Your content. The background paints behind it. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `skyTop` | `--sky-top` | `#0e1a30` | | `skyBottom` | `--sky-bottom` | `#33284d` | | `firefly` | `--firefly` | `#fbf8c4` | | `glow` | `--glow` | `#d6f36a` | | `grass` | `--grass` | `#0a1120` | | `moon` | `--moon` | `#f3ecd2` | #### Source `components/backgrounds/fireflies-background.tsx` ```tsx // : fireflies wandering and blinking over a dusky meadow, // with a crescent moon and swaying grass. Animated. import type { CSSProperties } from 'react'; import { BackgroundFrame, r2, seeded, type BackgroundProps } from './background'; import styles from './fireflies-background.module.css'; export const firefliesTheme = { skyTop: '#0e1a30', skyBottom: '#33284d', firefly: '#fbf8c4', glow: '#d6f36a', grass: '#0a1120', moon: '#f3ecd2', }; export type FirefliesTheme = typeof firefliesTheme; const rand = seeded(73); const offset = (range: number) => `${r2((rand() - 0.5) * range)}px`; const FIREFLIES = Array.from({ length: 30 }, () => ({ x: r2(3 + rand() * 94), y: r2(18 + rand() * 72), wander: r2(10 + rand() * 10), blink: r2(2.8 + rand() * 3.5), delay: r2(rand() * 10), path: { '--ax': offset(80), '--ay': offset(50), '--bx': offset(80), '--by': offset(50), '--cx': offset(80), '--cy': offset(50) }, scale: r2(0.7 + rand() * 0.7), })); // Blades are placed at percentage positions so the meadow spans any width without stretching. const BLADES = Array.from({ length: 70 }, () => ({ x: r2(rand() * 101), h: r2(28 + rand() * 52), lean: r2((rand() - 0.5) * 16), w: r2(2.5 + rand() * 2.5), sway: r2(2.5 + rand() * 2.5), delay: r2(rand() * 4), })); /** Fireflies wandering and blinking over a dusky meadow. */ export function FirefliesBackground(props: BackgroundProps) { return (
{FIREFLIES.map((f, i) => (
))} {BLADES.map((b, i) => ( ))} } /> ); } ``` `components/backgrounds/fireflies-background.module.css` ```css .sky { position: absolute; inset: 0; background: linear-gradient(var(--sky-top), var(--sky-bottom)); } .moon { position: absolute; top: 28px; right: 36px; width: 54px; height: 54px; filter: drop-shadow(0 0 14px rgb(255 245 210 / 0.35)); } /* Each firefly wanders a four-point loop and blinks on its own rhythm. */ .firefly { position: absolute; animation: wander ease-in-out infinite; } .light { display: block; width: 5px; height: 5px; border-radius: 50%; background: var(--firefly); box-shadow: 0 0 6px 2px var(--glow), 0 0 18px 6px color-mix(in srgb, var(--glow) 45%, transparent); animation: blink ease-in-out infinite; } .grass { position: absolute; left: 0; bottom: 0; width: 100%; height: 90px; overflow: visible; } .blade { animation: sway ease-in-out infinite alternate; } @keyframes wander { 0%, 100% { transform: translate(0, 0); } 25% { transform: translate(var(--ax), var(--ay)); } 50% { transform: translate(var(--bx), var(--by)); } 75% { transform: translate(var(--cx), var(--cy)); } } @keyframes blink { 0%, 35%, 100% { opacity: 0.12; } 50% { opacity: 1; } 65% { opacity: 0.3; } } @keyframes sway { from { transform: rotate(-4deg); } to { transform: rotate(4deg); } } ``` ### CloudsBackground > Twelve soft clouds in three parallax layers drift across a sky gradient: far clouds are small, faint and slow, near ones larger and quicker, each shaded toward its base. A pale sun glows gently in the corner. CSS keyframes and container units. - Page: https://www.boatui.dev/backgrounds/clouds - Source: https://github.com/wi11s/boatui/blob/main/components/backgrounds/clouds-background.tsx - Animated. Generate: ~1,051 tokens (source size, single pass). Import: ~20 tokens. #### Install All backgrounds: `npx degit wi11s/boatui/components/backgrounds components/backgrounds` Only this one: copy the shared files (`background.tsx`, `background.module.css`) and `clouds-background.tsx` and `clouds-background.module.css` into `components/backgrounds/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { CloudsBackground } from '@/components/backgrounds/clouds-background'; export default function Layout({ children }: { children: React.ReactNode }) { return {children}; } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `paused` | `boolean` | false | Freezes the animation. | | `className` | `string` | — | Applied to the root element. Size it like any block element. | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Your content. The background paints behind it. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `skyTop` | `--sky-top` | `#9ccbee` | | `skyBottom` | `--sky-bottom` | `#eef6fc` | | `cloud` | `--cloud` | `#ffffff` | | `cloudShade` | `--cloud-shade` | `#e2eef8` | | `sun` | `--sun` | `#fff4d1` | #### Source `components/backgrounds/clouds-background.tsx` ```tsx // : soft clouds drifting across the sky in three parallax layers, // under a gently glowing sun. Animated. import type { CSSProperties } from 'react'; import { BackgroundFrame, r2, seeded, type BackgroundProps } from './background'; import styles from './clouds-background.module.css'; export const cloudsTheme = { skyTop: '#9ccbee', skyBottom: '#eef6fc', cloud: '#ffffff', cloudShade: '#e2eef8', sun: '#fff4d1', }; export type CloudsTheme = typeof cloudsTheme; // Far layers are smaller, fainter and slower; the near layer is larger and quicker. const LAYERS = [ { count: 5, width: [80, 110], duration: [110, 140], opacity: 0.55, top: [8, 38] }, { count: 4, width: [130, 170], duration: [75, 95], opacity: 0.8, top: [20, 60] }, { count: 3, width: [190, 240], duration: [50, 62], opacity: 0.95, top: [45, 82] }, ]; const rand = seeded(88); const between = ([a, b]: number[]) => a + rand() * (b - a); const CLOUDS = LAYERS.flatMap(layer => Array.from({ length: layer.count }, (_, i) => { const duration = r2(between(layer.duration)); return { width: r2(between(layer.width)), top: r2(between(layer.top)), duration, // Spread evenly along the loop so the sky is never empty. delay: r2(duration * ((i + 0.2 + rand() * 0.6) / layer.count)), opacity: layer.opacity, flip: rand() > 0.5, }; }), ); /** One cloud: a flat-bottomed pill with three rounded puffs, shaded toward the base. */ function Cloud({ width, flip, gradient }: { width: number; flip: boolean; gradient: string }) { return ( ); } /** Soft clouds drifting across the sky in three parallax layers. */ export function CloudsBackground(props: BackgroundProps) { const gradient = 'qs-clouds-shade'; return (
{CLOUDS.map((c, i) => (
))} } /> ); } ``` `components/backgrounds/clouds-background.module.css` ```css .sky { position: absolute; inset: 0; background: linear-gradient(var(--sky-top), var(--sky-bottom)); } /* A soft sun: a pale disc inside a wide glow that breathes slowly. */ .sun { position: absolute; top: -60px; right: -40px; width: 280px; height: 280px; border-radius: 50%; background: radial-gradient(circle, var(--sun) 0 18%, color-mix(in srgb, var(--sun) 45%, transparent) 30%, transparent 68%); animation: glow 6s ease-in-out infinite alternate; } /* Clouds travel the full width, from just off the left edge to just off the right. */ .drift { position: absolute; left: 0; animation: drift linear infinite; will-change: transform; } @keyframes drift { from { transform: translateX(-280px); } to { transform: translateX(calc(100cqw + 40px)); } } @keyframes glow { from { opacity: 0.85; transform: scale(1); } to { opacity: 1; transform: scale(1.06); } } ``` ### Shared backgrounds files `components/backgrounds/background.tsx` ```tsx // Shared frame for every background: theming, layering and pausing. // Backgrounds are pure server components: no client JavaScript. import type { CSSProperties, ReactNode } from 'react'; import styles from './background.module.css'; export { styles as backgroundStyles }; /** Deterministic PRNG so particles render identically on server and client. */ export function seeded(seed: number): () => number { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Round for stable, compact inline styles. */ export const r2 = (n: number) => Math.round(n * 100) / 100; /** Props every background accepts. `T` is the background's theme (colour tokens). */ export type BackgroundProps = { /** Override any of the background's colours. */ theme?: Partial; /** Freeze animated backgrounds. Static backgrounds ignore it. */ paused?: boolean; className?: string; style?: CSSProperties; /** Your content. The background paints behind it. */ children?: ReactNode; }; // baseColor → --base-color, blob1 → --blob-1 const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`; type BackgroundFrameProps> = BackgroundProps & { defaultTheme: T; /** The texture itself, rendered in an absolutely positioned layer behind the children. */ layer: ReactNode; }; export function BackgroundFrame>({ defaultTheme, layer, theme, paused, className, style, children, }: BackgroundFrameProps) { const vars: Record = {}; for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) { if (value) vars[toVar(key)] = value; } const classes = [styles.root, paused && styles.paused, className].filter(Boolean).join(' '); return (
{children}
); } ``` `components/backgrounds/background.module.css` ```css /* The root establishes a stacking context so the layer sits behind children without escaping behind the page. */ .root { position: relative; isolation: isolate; } .layer { position: absolute; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; /* Lets particles move by the layer's own size: 100cqh is its full height, 100cqw its width. */ container-type: size; } /* A particle starts just above the layer at a random horizontal position. */ .particle { position: absolute; top: 0; } .paused, .paused * { animation-play-state: paused !important; } @media (prefers-reduced-motion: reduce) { .layer, .layer * { animation: none !important; } /* Without motion, particles rest at a scattered position instead of the top edge. */ .particle { top: var(--y, 0); } } ``` ## Empty states Empty states are small animated illustrations for screens with nothing to show yet: empty lists, no search results, inbox zero, offline. Pass your message and actions as children and they appear centred under the illustration. Pure server components with CSS animation, drawn in a 240×180 box. Props: `theme`, `size` (illustration width, default 240), `paused`, `className`, `style`, `children`. ```bash npx degit wi11s/boatui/components/empty-states components/empty-states ``` Shared files add ~562 tokens once. ### EmptyNap > A cat curled up asleep on a cushion: its body rises and falls as it breathes, its tail swishes, an ear twitches now and then, and Zs drift up and fade. For empty lists and "nothing here yet" screens. - Page: https://www.boatui.dev/empty-states/nap - Source: https://github.com/wi11s/boatui/blob/main/components/empty-states/empty-nap.tsx - Animated. Generate: ~1,188 tokens (source size, single pass). Import: ~17 tokens. #### Install All empty states: `npx degit wi11s/boatui/components/empty-states components/empty-states` Only this one: copy the shared files (`empty-state.tsx`, `empty-state.module.css`) and `empty-nap.tsx` and `empty-nap.module.css` into `components/empty-states/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx import { EmptyNap } from '@/components/empty-states/empty-nap'; export function NoProjects() { return (

No projects yet

Create one to get started.

); } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `Partial` | — | Colour overrides. Keys listed under theme tokens. | | `size` | `number` | 240 | Illustration width in pixels. Scales down to fit narrow containers. | | `paused` | `boolean` | false | Freezes the animation. | | `className` | `string` | — | Applied to the root element (a centred column). | | `style` | `CSSProperties` | — | Merged into the root element style, after theme variables. | | `children` | `ReactNode` | — | Your message and actions, shown under the illustration. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | | `cat` | `--cat` | `#f2a65a` | | `stripes` | `--stripes` | `#d9823b` | | `cushion` | `--cushion` | `#9cc9e3` | | `cushionShade` | `--cushion-shade` | `#7fb2d2` | | `blush` | `--blush` | `#f7b7c8` | | `ink` | `--ink` | `#4a3a33` | | `zzz` | `--zzz` | `#8a97a0` | #### Source `components/empty-states/empty-nap.tsx` ```tsx // : a cat curled up asleep on a cushion, breathing slowly, with Zs drifting up. // For "nothing here yet" screens. Animated. import { EmptyStateFrame, type EmptyStateProps } from './empty-state'; import styles from './empty-nap.module.css'; export const emptyNapTheme = { cat: '#f2a65a', stripes: '#d9823b', cushion: '#9cc9e3', cushionShade: '#7fb2d2', blush: '#f7b7c8', ink: '#4a3a33', zzz: '#8a97a0', }; export type EmptyNapTheme = typeof emptyNapTheme; /** A cat curled up asleep on a cushion, for "nothing here yet" screens. */ export function EmptyNap(props: EmptyStateProps) { return ( {/* Cushion */} {/* Tail curls around the front, hinged at the hip */} {/* Body */} {/* Head resting on the front paws */} {/* Zs drifting up from the head */} {[0, 1.2, 2.4].map((delay, i) => ( // Position on the group; the path's own transform is the CSS float. ))} } /> ); } ``` `components/empty-states/empty-nap.module.css` ```css /* The cat breathes from its belly, its tail swishes, and Zs drift up and fade. */ .breathe { transform-box: fill-box; transform-origin: 50% 100%; animation: breathe 2.6s ease-in-out infinite; } .tail { transform-box: fill-box; transform-origin: 0% 50%; animation: swish 3.2s ease-in-out infinite; } .ear { transform-box: fill-box; transform-origin: 50% 100%; animation: twitch 7s ease-in-out infinite; } .z { animation: float 3.6s ease-out infinite; opacity: 0; } .star { animation: twinkle 2.4s ease-in-out infinite alternate; } @keyframes breathe { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(1.05); } } @keyframes swish { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-12deg); } } @keyframes twitch { 0%, 92%, 100% { transform: rotate(0deg); } 95% { transform: rotate(-14deg); } } @keyframes float { 0% { transform: translate(0, 0) scale(0.6); opacity: 0; } 20% { opacity: 1; } 100% { transform: translate(18px, -46px) scale(1.15); opacity: 0; } } @keyframes twinkle { from { opacity: 0.25; } to { opacity: 1; } } ``` ### Shared empty states files `components/empty-states/empty-state.tsx` ```tsx // Shared frame for every empty state: an illustration with your message underneath. // Empty states are pure server components: no client JavaScript. import type { CSSProperties, ReactNode } from 'react'; import styles from './empty-state.module.css'; /** Props every empty state accepts. `T` is the illustration's theme (colour tokens). */ export type EmptyStateProps = { /** Override any of the illustration's colours. */ theme?: Partial; /** Freeze the animation. */ paused?: boolean; /** Illustration width in pixels (default 240). It scales down to fit narrow containers. */ size?: number; className?: string; style?: CSSProperties; /** Your message and actions, shown under the illustration. */ children?: ReactNode; }; // skyTop → --sky-top, leaf1 → --leaf-1 const toVar = (key: string) => `--${key.replace(/([A-Z])/g, '-$1').replace(/(\d+)/g, '-$1').toLowerCase()}`; type EmptyStateFrameProps> = EmptyStateProps & { defaultTheme: T; /** SVG content drawn in a 240×180 box. */ art: ReactNode; }; export function EmptyStateFrame>({ defaultTheme, art, theme, paused, size = 240, className, style, children, }: EmptyStateFrameProps) { const vars: Record = {}; for (const [key, value] of Object.entries({ ...defaultTheme, ...theme })) { if (value) vars[toVar(key)] = value; } const classes = [styles.root, paused && styles.paused, className].filter(Boolean).join(' '); return (
{children &&
{children}
}
); } ``` `components/empty-states/empty-state.module.css` ```css .root { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; padding: 24px; text-align: center; } .art { display: block; max-width: 100%; height: auto; overflow: visible; } .content { max-width: 360px; } .paused * { animation-play-state: paused !important; } @media (prefers-reduced-motion: reduce) { .art * { animation: none !important; } } ``` ## 3D 3D scenes are three.js client components. A shared frame (`three-frame.tsx`) owns the canvas: it sizes to its container (4:3 by default), caps pixel ratio at 2, stops rendering while off-screen, in hidden tabs or when `paused`, shows one still frame under prefers-reduced-motion, falls back to the CSS background when WebGL is unavailable, and disposes every geometry and material on unmount. Each scene is one file with a module-level `setup` that builds the scene and returns `update(time)` and `setTheme(theme)`, so colour changes apply live. Props: `theme`, `speed`, `paused`, `className`, `style`, `children`. ```bash npx degit wi11s/boatui/components/three components/three npm i three ``` Shared files add ~1,620 tokens once. ### TinyPlanet3D > A small, slightly lumpy low-poly planet turning slowly: 22 trees, three cottages with lit windows, a windmill with spinning blades, five clouds on tilted orbits, a moon and a 300-star field. Flat-shaded three.js with hemisphere, key and rim lights. - Page: https://www.boatui.dev/3d/tiny-planet - Source: https://github.com/wi11s/boatui/blob/main/components/three/tiny-planet-3d.tsx - Animated. Generate: ~2,037 tokens (source size, single pass). Import: ~17 tokens. #### Install All 3d: `npx degit wi11s/boatui/components/three components/three`, then `npm i three` Only this one: copy the shared files (`three-frame.tsx`, `three-frame.module.css`) and `tiny-planet-3d.tsx` into `components/three/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx 'use client'; import { TinyPlanet3D } from '@/components/three/tiny-planet-3d'; export function Hero() { return (

Your content

); } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `Partial` | — | Colour overrides. Applied live without rebuilding the scene. | | `speed` | `number` | 1 | Animation speed multiplier. | | `paused` | `boolean` | false | Freezes the animation. Also stops rendering while off-screen or in a hidden tab. | | `className` | `string` | — | Applied to the root element (4:3 by default; override the aspect ratio or height here). | | `style` | `CSSProperties` | — | Merged into the root element style. | | `children` | `ReactNode` | — | Rendered above the canvas, filling it. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | #### Source `components/three/tiny-planet-3d.tsx` ```tsx 'use client'; // : a small low-poly planet turning slowly, with trees, cottages, // a windmill, orbiting clouds, a moon and a starfield. three.js. import * as THREE from 'three'; import { ThreeFrame, seeded, type SceneSetup, type ThreeSceneProps } from './three-frame'; export const tinyPlanetTheme = { skyTop: '#141a3a', skyBottom: '#3d3270', ground: '#7cc576', leaves: '#3f9d5a', trunk: '#8a5a3c', walls: '#fbf3e4', roof: '#e4573d', cloud: '#ffffff', moon: '#e3ddd0', stars: '#ffffff', }; export type TinyPlanetTheme = typeof tinyPlanetTheme; const RADIUS = 1.8; const UP = new THREE.Vector3(0, 1, 0); /** Stand an object on the surface, pointing outward along `dir`. */ function plant(object: THREE.Object3D, dir: THREE.Vector3, lift = 0) { object.position.copy(dir).multiplyScalar(RADIUS - 0.02 + lift); object.quaternion.setFromUnitVectors(UP, dir); return object; } function randomDir(rand: () => number) { const u = rand() * 2 - 1; const theta = rand() * Math.PI * 2; const s = Math.sqrt(1 - u * u); return new THREE.Vector3(s * Math.cos(theta), u, s * Math.sin(theta)); } const setup: SceneSetup = ({ scene, camera, theme }) => { const rand = seeded(5); camera.position.set(0, 1.4, 9.5); camera.lookAt(0, 0, 0); const flat = (color: string, extra: THREE.MeshStandardMaterialParameters = {}) => new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.85, ...extra }); const mats = { ground: flat(theme.ground), leaves: flat(theme.leaves), trunk: flat(theme.trunk), walls: flat(theme.walls), roof: flat(theme.roof), cloud: flat(theme.cloud, { roughness: 1 }), moon: flat(theme.moon), door: flat('#5a3e2b'), window: new THREE.MeshStandardMaterial({ color: '#ffd98a', emissive: '#ffb84d', emissiveIntensity: 0.9 }), }; const hemi = new THREE.HemisphereLight('#dfe8ff', theme.skyBottom, 1.3); const sun = new THREE.DirectionalLight('#fff1d6', 2.4); sun.position.set(5, 6, 4); const rim = new THREE.DirectionalLight('#9fb4ff', 0.9); rim.position.set(-4, 2, -5); scene.add(hemi, sun, rim); // Planet: an icosphere with gentle seeded bumps. const world = new THREE.Group(); world.rotation.z = 0.25; scene.add(world); const planetGeo = new THREE.IcosahedronGeometry(RADIUS, 4); const pos = planetGeo.attributes.position; const v = new THREE.Vector3(); const bumps = new Map(); for (let i = 0; i < pos.count; i++) { v.fromBufferAttribute(pos, i); const key = `${v.x.toFixed(3)},${v.y.toFixed(3)},${v.z.toFixed(3)}`; // shared vertices move together if (!bumps.has(key)) bumps.set(key, 1 + (rand() - 0.5) * 0.035); v.multiplyScalar(bumps.get(key)!); pos.setXYZ(i, v.x, v.y, v.z); } planetGeo.computeVertexNormals(); world.add(new THREE.Mesh(planetGeo, mats.ground)); // Cottages first, so trees can keep their distance. const taken: THREE.Vector3[] = []; const house = (dir: THREE.Vector3) => { const g = new THREE.Group(); const walls = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.26, 0.3), mats.walls); walls.position.y = 0.13; const roof = new THREE.Mesh(new THREE.ConeGeometry(0.29, 0.2, 4), mats.roof); roof.position.y = 0.36; roof.rotation.y = Math.PI / 4; const door = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.12, 0.02), mats.door); door.position.set(0, 0.06, 0.155); const window = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.02), mats.window); window.position.set(0.09, 0.15, 0.155); g.add(walls, roof, door, window); g.rotateY(rand() * Math.PI * 2); taken.push(dir); world.add(plant(g, dir)); }; [new THREE.Vector3(0.3, 0.8, 0.5), new THREE.Vector3(-0.7, 0.2, 0.7), new THREE.Vector3(0.6, -0.3, -0.7)].forEach(d => house(d.normalize()), ); // Windmill with four blades. const blades = new THREE.Group(); { const dir = new THREE.Vector3(-0.2, 0.7, -0.68).normalize(); taken.push(dir); const g = new THREE.Group(); const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.11, 0.7, 6), mats.walls); tower.position.y = 0.35; const cap = new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.14, 6), mats.roof); cap.position.y = 0.76; blades.position.set(0, 0.68, 0.1); for (let i = 0; i < 4; i++) { const blade = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.36, 0.01), mats.walls); blade.position.y = 0.18; const arm = new THREE.Group(); arm.rotation.z = (i * Math.PI) / 2; arm.add(blade); blades.add(arm); } g.add(tower, cap, blades); world.add(plant(g, dir)); } // Trees: a mix of cones and round tops, kept away from buildings. let planted = 0; while (planted < 22) { const dir = randomDir(rand); if (taken.some(t => t.angleTo(dir) < 0.32)) continue; taken.push(dir); planted++; const g = new THREE.Group(); const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.05, 0.2, 5), mats.trunk); trunk.position.y = 0.1; const round = rand() > 0.5; const top = round ? new THREE.Mesh(new THREE.IcosahedronGeometry(0.17, 0), mats.leaves) : new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.4, 6), mats.leaves); top.position.y = round ? 0.32 : 0.38; g.add(trunk, top); g.scale.setScalar(0.8 + rand() * 0.5); world.add(plant(g, dir)); } // Clouds on tilted orbits, each a cluster of puffs. const orbits: { pivot: THREE.Object3D; speed: number }[] = []; for (let i = 0; i < 5; i++) { const pivot = new THREE.Object3D(); pivot.rotation.set((rand() - 0.5) * 1.6, rand() * Math.PI * 2, (rand() - 0.5) * 1.2); const cloud = new THREE.Group(); const puffs = 3 + Math.floor(rand() * 2); for (let p = 0; p < puffs; p++) { const puff = new THREE.Mesh(new THREE.IcosahedronGeometry(0.13 + rand() * 0.08, 1), mats.cloud); puff.position.set((p - puffs / 2) * 0.16, rand() * 0.06, (rand() - 0.5) * 0.08); cloud.add(puff); } cloud.position.set(0, 0, RADIUS + 0.75); pivot.add(cloud); scene.add(pivot); orbits.push({ pivot, speed: 0.12 + rand() * 0.12 }); } // Moon. const moonPivot = new THREE.Object3D(); moonPivot.rotation.x = 0.35; const moon = new THREE.Mesh(new THREE.IcosahedronGeometry(0.28, 1), mats.moon); moon.position.set(3.4, 0.4, 0); moonPivot.add(moon); scene.add(moonPivot); // Stars on a distant shell. const starPositions = new Float32Array(300 * 3); for (let i = 0; i < 300; i++) { const d = randomDir(rand).multiplyScalar(40 + rand() * 20); starPositions.set([d.x, d.y, d.z], i * 3); } const starGeo = new THREE.BufferGeometry(); starGeo.setAttribute('position', new THREE.BufferAttribute(starPositions, 3)); const starMat = new THREE.PointsMaterial({ color: theme.stars, size: 0.18, sizeAttenuation: true, transparent: true, opacity: 0.85 }); const stars = new THREE.Points(starGeo, starMat); scene.add(stars); return { update(time) { world.rotation.y = time * 0.12; world.position.y = Math.sin(time * 0.6) * 0.05; blades.rotation.z = time * 1.8; orbits.forEach((o, i) => o.pivot.rotation.set(o.pivot.rotation.x, i * 1.3 + time * o.speed, o.pivot.rotation.z)); moonPivot.rotation.y = time * 0.08; moon.rotation.y = time * 0.2; stars.rotation.y = time * 0.004; }, setTheme(t) { mats.ground.color.set(t.ground); mats.leaves.color.set(t.leaves); mats.trunk.color.set(t.trunk); mats.walls.color.set(t.walls); mats.roof.color.set(t.roof); mats.cloud.color.set(t.cloud); mats.moon.color.set(t.moon); starMat.color.set(t.stars); hemi.groundColor.set(t.skyBottom); }, }; }; /** A small low-poly planet turning slowly, with trees, cottages, a windmill and orbiting clouds. */ export function TinyPlanet3D(props: ThreeSceneProps) { return ( `radial-gradient(circle at 50% 45%, ${t.skyBottom}, ${t.skyTop} 75%)`} /> ); } ``` ### Lagoon3D > A sailboat circling a small palm-tree island on a flat-shaded low-poly sea. The boat samples the same wave function as the water, so it pitches and rolls with the swell. Gulls flap overhead, clouds drift past, and distance fog blends the sea into a warm horizon. three.js. - Page: https://www.boatui.dev/3d/lagoon - Source: https://github.com/wi11s/boatui/blob/main/components/three/lagoon-3d.tsx - Animated. Generate: ~2,227 tokens (source size, single pass). Import: ~15 tokens. #### Install All 3d: `npx degit wi11s/boatui/components/three components/three`, then `npm i three` Only this one: copy the shared files (`three-frame.tsx`, `three-frame.module.css`) and `lagoon-3d.tsx` into `components/three/`. Shared files are in https://www.boatui.dev/llms-full.txt. #### Usage ```tsx 'use client'; import { Lagoon3D } from '@/components/three/lagoon-3d'; export function Hero() { return (

Your content

); } ``` #### Props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `Partial` | — | Colour overrides. Applied live without rebuilding the scene. | | `speed` | `number` | 1 | Animation speed multiplier. | | `paused` | `boolean` | false | Freezes the animation. Also stops rendering while off-screen or in a hidden tab. | | `className` | `string` | — | Applied to the root element (4:3 by default; override the aspect ratio or height here). | | `style` | `CSSProperties` | — | Merged into the root element style. | | `children` | `ReactNode` | — | Rendered above the canvas, filling it. | #### Theme tokens | Key | CSS variable | Default | | --- | --- | --- | #### Source `components/three/lagoon-3d.tsx` ```tsx 'use client'; // : a sailboat circling a palm-tree island on a low-poly sea. The boat samples // the same wave function as the water, so it pitches and rolls with the swell. three.js. import * as THREE from 'three'; import { ThreeFrame, seeded, type SceneSetup, type ThreeSceneProps } from './three-frame'; export const lagoonTheme = { skyTop: '#7fb6e0', horizon: '#dcebf2', water: '#2f86a8', sand: '#f0d9a6', grass: '#6cbf6a', palm: '#3f9d5a', trunk: '#9a6b45', hull: '#e4573d', sail: '#fbf8f1', cloud: '#ffffff', }; export type LagoonTheme = typeof lagoonTheme; /** Height of the sea at (x, z) and time t. Shared by the water mesh and the boat. */ const waveHeight = (x: number, z: number, t: number) => 0.13 * Math.sin(0.55 * x + 1.1 * t) + 0.09 * Math.sin(0.8 * z - 0.8 * t + 1.3) + 0.05 * Math.sin(1.6 * (x + z) + 1.9 * t); const BOAT_ORBIT = 4.3; function makeBoat(mats: Record) { const boat = new THREE.Group(); const outline = new THREE.Shape(); outline.moveTo(-0.9, -0.32); outline.lineTo(0.35, -0.34); outline.quadraticCurveTo(0.8, -0.26, 1.05, 0); outline.quadraticCurveTo(0.8, 0.26, 0.35, 0.34); outline.lineTo(-0.9, 0.32); outline.closePath(); const hullGeo = new THREE.ExtrudeGeometry(outline, { depth: 0.4, bevelEnabled: false, curveSegments: 6 }); hullGeo.rotateX(Math.PI / 2); const p = hullGeo.attributes.position; for (let i = 0; i < p.count; i++) p.setZ(i, p.getZ(i) * (1 + 0.9 * p.getY(i) / 0.4)); // taper toward the keel hullGeo.translate(0, 0.22, 0); hullGeo.computeVertexNormals(); boat.add(new THREE.Mesh(hullGeo, mats.hull)); const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.03, 1.5, 5), mats.trunk); mast.position.set(0.15, 0.95, 0); boat.add(mast); const sail = (points: number[]) => { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(points, 3)); g.computeVertexNormals(); return new THREE.Mesh(g, mats.sail); }; boat.add(sail([0.13, 0.35, 0, 0.13, 1.65, 0, -0.75, 0.35, 0])); boat.add(sail([0.18, 0.4, 0, 0.18, 1.45, 0, 0.95, 0.35, 0])); return boat; } function makeGull(mat: THREE.Material) { const gull = new THREE.Group(); // Each wing is a swept triangle hinged at the body. const wing = new THREE.BufferGeometry(); wing.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0.07, 0, 0, -0.06, 0.26, 0, -0.1], 3)); wing.computeVertexNormals(); const left = new THREE.Mesh(wing, mat); const right = new THREE.Mesh(wing, mat); right.scale.x = -1; const body = new THREE.Mesh(new THREE.ConeGeometry(0.03, 0.18, 4), mat); body.rotation.x = Math.PI / 2; gull.add(left, right, body); return { gull, left, right }; } const setup: SceneSetup = ({ scene, camera, theme }) => { const rand = seeded(19); camera.position.set(0, 6, 13.5); camera.lookAt(0, 0.4, 0); scene.fog = new THREE.Fog(theme.horizon, 16, 34); const flat = (color: string, extra: THREE.MeshStandardMaterialParameters = {}) => new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.8, ...extra }); const mats = { water: flat(theme.water, { roughness: 0.35, metalness: 0.05 }), sand: flat(theme.sand), grass: flat(theme.grass), palm: flat(theme.palm, { side: THREE.DoubleSide }), trunk: flat(theme.trunk), hull: flat(theme.hull), sail: flat(theme.sail, { side: THREE.DoubleSide }), cloud: flat(theme.cloud, { roughness: 1 }), gull: flat('#ffffff', { side: THREE.DoubleSide }), }; const hemi = new THREE.HemisphereLight('#ffffff', theme.water, 1.4); const sun = new THREE.DirectionalLight('#fff4e0', 2.2); sun.position.set(-6, 9, 5); scene.add(hemi, sun); // Sea: a flat-shaded grid whose heights are rewritten each frame. const waterGeo = new THREE.PlaneGeometry(60, 60, 72, 72); waterGeo.rotateX(-Math.PI / 2); const waterPos = waterGeo.attributes.position; scene.add(new THREE.Mesh(waterGeo, mats.water)); // Island: a flattened low-poly dome of sand with a grassy cap and a palm. const island = new THREE.Group(); const sandGeo = new THREE.SphereGeometry(2, 9, 5, 0, Math.PI * 2, 0, Math.PI / 2); sandGeo.scale(1, 0.32, 1); const grassGeo = new THREE.SphereGeometry(1.35, 8, 4, 0, Math.PI * 2, 0, Math.PI / 2); grassGeo.scale(1, 0.28, 1); const grass = new THREE.Mesh(grassGeo, mats.grass); grass.position.y = 0.36; island.add(new THREE.Mesh(sandGeo, mats.sand), grass); const palm = new THREE.Group(); let tip = new THREE.Vector3(0, 0.45, 0); for (let i = 0; i < 6; i++) { const seg = new THREE.Mesh(new THREE.CylinderGeometry(0.07 - i * 0.006, 0.08 - i * 0.006, 0.36, 6), mats.trunk); const next = tip.clone().add(new THREE.Vector3(0.05 + i * 0.012, 0.33, 0)); seg.position.copy(tip).lerp(next, 0.5); seg.lookAt(next); seg.rotateX(Math.PI / 2); palm.add(seg); tip = next; } const fronds = new THREE.Group(); fronds.position.copy(tip); for (let i = 0; i < 7; i++) { const leaf = new THREE.Mesh(new THREE.ConeGeometry(0.16, 1.05, 4, 1, true), mats.palm); leaf.scale.set(1, 1, 0.25); leaf.position.y = 0.42; const arm = new THREE.Group(); arm.rotation.set(1.05 + (i % 2) * 0.2, (i / 7) * Math.PI * 2, 0, 'YXZ'); arm.add(leaf); fronds.add(arm); } palm.add(fronds); palm.position.set(-0.3, 0, 0.1); island.add(palm); scene.add(island); const boat = makeBoat(mats); scene.add(boat); const gulls = Array.from({ length: 3 }, (_, i) => { const g = makeGull(mats.gull); scene.add(g.gull); return { ...g, radius: 2.5 + i * 0.9, height: 3.6 + i * 0.5, speed: 0.35 + rand() * 0.2, phase: rand() * 6 }; }); const clouds = Array.from({ length: 5 }, () => { const cloud = new THREE.Group(); const puffs = 3 + Math.floor(rand() * 3); for (let p = 0; p < puffs; p++) { const puff = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5 + rand() * 0.4, 1), mats.cloud); puff.position.set((p - puffs / 2) * 0.6, rand() * 0.25, (rand() - 0.5) * 0.4); cloud.add(puff); } cloud.position.set((rand() - 0.5) * 30, 3.6 + rand() * 1.6, -10 - rand() * 6); scene.add(cloud); return { cloud, speed: 0.25 + rand() * 0.25, start: cloud.position.x }; }); let pitch = 0; let roll = 0; return { update(t) { for (let i = 0; i < waterPos.count; i++) { waterPos.setY(i, waveHeight(waterPos.getX(i), waterPos.getZ(i), t)); } waterPos.needsUpdate = true; waterGeo.computeVertexNormals(); // Boat circles the island, heading along its path and riding the swell. const angle = -t * 0.14; const x = Math.cos(angle) * BOAT_ORBIT; const z = Math.sin(angle) * BOAT_ORBIT; const heading = Math.atan2(Math.cos(angle), -Math.sin(angle)) - Math.PI / 2; const ahead = new THREE.Vector2(Math.sin(angle), -Math.cos(angle)); const side = new THREE.Vector2(Math.cos(angle), Math.sin(angle)); const h = waveHeight(x, z, t); const hBow = waveHeight(x + ahead.x * 0.9, z + ahead.y * 0.9, t); const hStern = waveHeight(x - ahead.x * 0.9, z - ahead.y * 0.9, t); const hPort = waveHeight(x + side.x * 0.35, z + side.y * 0.35, t); const hStar = waveHeight(x - side.x * 0.35, z - side.y * 0.35, t); pitch += (Math.atan2(hBow - hStern, 1.8) - pitch) * 0.1; roll += (Math.atan2(hPort - hStar, 0.7) - roll) * 0.1; boat.position.set(x, h - 0.06, z); boat.rotation.set(0, heading, 0); boat.rotateZ(pitch); boat.rotateX(roll); fronds.rotation.z = Math.sin(t * 1.3) * 0.05; gulls.forEach(g => { const a = t * g.speed + g.phase; g.gull.position.set(Math.cos(a) * g.radius, g.height + Math.sin(t * 0.9 + g.phase) * 0.2, Math.sin(a) * g.radius); g.gull.rotation.y = -a; const flap = 0.25 + Math.sin(t * 7 + g.phase) * 0.5; // V-shaped glide with flaps g.left.rotation.z = flap; g.right.rotation.z = -flap; }); clouds.forEach(c => { c.cloud.position.x = ((c.start + t * c.speed + 20) % 40) - 20; }); }, setTheme(th) { mats.water.color.set(th.water); mats.sand.color.set(th.sand); mats.grass.color.set(th.grass); mats.palm.color.set(th.palm); mats.trunk.color.set(th.trunk); mats.hull.color.set(th.hull); mats.sail.color.set(th.sail); mats.cloud.color.set(th.cloud); hemi.groundColor.set(th.water); (scene.fog as THREE.Fog).color.set(th.horizon); }, }; }; /** A sailboat circling a palm-tree island on a low-poly sea. */ export function Lagoon3D(props: ThreeSceneProps) { return ( `linear-gradient(${t.skyTop}, ${t.horizon} 62%)`} /> ); } ``` ### Shared 3d files `components/three/three-frame.tsx` ```tsx 'use client'; // Shared frame for every 3D scene: canvas sizing, render loop, pausing, theming and cleanup. // Scenes provide a module-level `setup` function; this frame owns everything else. import { useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from 'react'; import * as THREE from 'three'; import styles from './three-frame.module.css'; /** Props every 3D scene accepts. `T` is the scene's theme (colour tokens). */ export type ThreeSceneProps = { /** Override any of the scene's colours. Updates live without rebuilding the scene. */ theme?: Partial; /** Freeze the animation. Scenes also stop rendering while off-screen or in a hidden tab. */ paused?: boolean; /** Animation speed multiplier (default 1). */ speed?: number; className?: string; style?: CSSProperties; /** Content rendered on top of the canvas. */ children?: ReactNode; }; export type SceneContext = { scene: THREE.Scene; camera: THREE.PerspectiveCamera; renderer: THREE.WebGLRenderer; theme: T; }; export type SceneController = { /** Advance the scene to `time` seconds (scaled by `speed`). */ update: (time: number) => void; /** Apply new colours to existing materials and lights. */ setTheme: (theme: T) => void; }; /** Build the scene once. Must be defined at module level so its identity is stable. */ export type SceneSetup = (ctx: SceneContext) => SceneController; /** Deterministic PRNG so scenes look the same on every load. */ export function seeded(seed: number): () => number { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } type ThreeFrameProps> = ThreeSceneProps & { defaultTheme: T; setup: SceneSetup; /** CSS background behind the transparent canvas, e.g. a sky gradient. */ background?: (theme: T) => string; /** Time shown as a still frame under prefers-reduced-motion. */ stillTime?: number; }; export function ThreeFrame>({ defaultTheme, setup, background, stillTime = 4, theme, paused = false, speed = 1, className, style, children, }: ThreeFrameProps) { const hostRef = useRef(null); const merged = useMemo(() => ({ ...defaultTheme, ...theme }) as T, [defaultTheme, theme]); // Live values read by the render loop without restarting it. const live = useRef({ theme: merged, paused, speed }); live.current.speed = speed; const control = useRef<{ schedule: () => void; redraw: () => void; setTheme: (t: T) => void } | null>(null); useEffect(() => { const host = hostRef.current; if (!host) return; let renderer: THREE.WebGLRenderer; try { renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'low-power' }); } catch { return; // No WebGL: leave the background and children visible. } renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.domElement.className = styles.canvas; host.prepend(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 200); const controller = setup({ scene, camera, renderer, theme: live.current.theme }); const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); let visible = true; let time = reduceMotion.matches ? stillTime : 0; let last = 0; let raf = 0; const draw = () => { controller.update(time); renderer.render(scene, camera); }; const running = () => visible && !live.current.paused && !reduceMotion.matches && !document.hidden; const frame = (now: number) => { raf = 0; if (!running()) return; const delta = Math.min((now - last) / 1000, 0.05); last = now; time += delta * live.current.speed; draw(); raf = requestAnimationFrame(frame); }; const schedule = () => { if (reduceMotion.matches) time = stillTime; if (running() && !raf) { last = performance.now(); raf = requestAnimationFrame(frame); } else if (!running()) { draw(); } }; const resize = () => { const { width, height } = host.getBoundingClientRect(); if (!width || !height) return; renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); draw(); }; const resizeObserver = new ResizeObserver(resize); resizeObserver.observe(host); const intersectionObserver = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; schedule(); }); intersectionObserver.observe(host); document.addEventListener('visibilitychange', schedule); reduceMotion.addEventListener('change', schedule); control.current = { schedule, redraw: draw, setTheme: controller.setTheme }; resize(); schedule(); return () => { cancelAnimationFrame(raf); resizeObserver.disconnect(); intersectionObserver.disconnect(); document.removeEventListener('visibilitychange', schedule); reduceMotion.removeEventListener('change', schedule); control.current = null; scene.traverse(object => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); const materials = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : []; materials.forEach(m => m.dispose()); }); renderer.dispose(); renderer.domElement.remove(); }; }, [setup, stillTime]); useEffect(() => { live.current.theme = merged; control.current?.setTheme(merged); control.current?.redraw(); }, [merged]); useEffect(() => { live.current.paused = paused; control.current?.schedule(); }, [paused]); const classes = [styles.root, className].filter(Boolean).join(' '); return (
{children &&
{children}
}
); } ``` `components/three/three-frame.module.css` ```css .root { position: relative; display: block; width: 100%; aspect-ratio: 4 / 3; overflow: hidden; } .canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } .overlay { position: absolute; inset: 0; } ```