# PixelScene

> An animated pixel-art landscape on a dot grid: sparse ink dots at rest, full colour under a round lens that follows the pointer, separate day and night palettes, and on HDR displays lit windows, stars and the sun drawn as squares brighter than SDR white. Four scenes: alpine, meadow, sea and mountains.

- Package: `fancy-ui-svelte` (Svelte 5 + Tailwind CSS v4)
- Category: Effects
- Tags: pixel, dither, canvas, scene, lens, hdr, landscape
- Types: `PixelSceneProps`, `PixelSceneName`, `PixelSceneHdrLook`
- Docs: https://fancy-ui.rama.app/docs/components/pixel-scene
- Source: https://github.com/RamaHerbin/fancy-ui/tree/main/src/lib/fancy-ui/pixel-scene

## Import

```svelte
<script lang="ts">
  import { PixelScene } from 'fancy-ui-svelte';
</script>
```

Setup (once per project): `npm install fancy-ui-svelte`, then add `@import "fancy-ui-svelte/tailwind.css";` after `@import "tailwindcss";` in your main CSS file. Full guide: https://fancy-ui.rama.app/llms.txt

React: `import { PixelScene } from 'fancy-ui-react';` (`class` becomes `className`, snippets become `ReactNode`, DOM callbacks are camel-cased; exact names on https://fancy-ui.rama.app/docs/components/react/pixel-scene.md).

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `scene` | `"alpine" \| "meadow" \| "sea" \| "mountains"` | `"alpine"` | Which landscape to paint. |
| `night` | `boolean` | `false` | Night palette: stars, moonlit snow, lit windows. The component never changes it; toggle it from your own click or theme. |
| `cell` | `number` | `5` | Grid pitch in CSS pixels. Very large boxes use a coarser pitch so a frame stays under about 72 000 cells. |
| `dot` | `number` | `4` | Edge of each square dot in CSS pixels (at most `cell`). |
| `lens` | `boolean` | `true` | Shows the picture in full colour under a round lens that follows the pointer. |
| `lensRadius` | `number` | `21` | Lens radius in cells. Its rim is dithered on the grid. |
| `hdr` | `boolean` | `true` | On a display that reports HDR, redraws the sun, windows, stars and the lens in a WebGPU layer brighter than SDR white. Elsewhere nothing is created. |
| `hdrLook` | `"calibrated" \| "vivid"` | `"calibrated"` | HDR rendering: calibrated levels (glow 1 is the SDR dot, sRGB), or vivid, a hotter, wider-gamut look (display-p3) with lamps at the panel peak. |
| `ink` | `string` | `"currentColor"` | Colour of the resting dots outside the lens. Resolved on the host, so it follows the theme. |
| `paused` | `boolean` | `false` | Freezes clouds, birds and lights; the lens still follows the pointer. |
| `wave` | `boolean` | `true` | Plays a change of `night` as a dithered wave spreading from the sun (or moon), led by a halo that glows on HDR displays. Off under reduced motion. |
| `surfaces` | `readonly [day: string, night: string]` | `—` | Background painted by the scene for each theme; the wave carries it from one to the other. Without it the canvas is transparent. |
| `inks` | `readonly [day: string, night: string]` | `—` | Dot colour for each theme, swapped by the wave. Overrides `ink`. |
| `class` | `string` | — | Additional classes on the host. Give it a height. |

## Snippets

| Snippet | Description |
| --- | --- |
| `children` | Copy rendered above the picture. Keep it in the top-left corner, which every scene leaves open. |

## Examples

### Basic Usage

```svelte
<script lang="ts">
	import { PixelScene } from "fancy-ui-svelte";

	// The component never changes `night`: the page owns it. A click anywhere on the
	// picture flips it, and the button gives keyboard users the same switch.
	let night = $state(false);
</script>

<PixelScene
	scene="alpine"
	{night}
	onclick={() => (night = !night)}
	class="border-foreground/10 text-foreground h-[340px] w-full cursor-pointer rounded-2xl border"
>
	<div class="pointer-events-none absolute top-5 left-5 max-w-[460px] pr-5 sm:top-9 sm:left-9">
		<p class="text-muted-foreground font-mono text-[11px] tracking-[0.2em] uppercase">
			Season notes · {night ? "After dark" : "Morning"}
		</p>
		<h3 class="mt-2 text-3xl leading-tight font-semibold tracking-tight sm:text-4xl">
			First snow on the ridge
		</h3>
		<p class="text-muted-foreground mt-3 hidden max-w-sm text-sm leading-relaxed sm:block">
			Move across the picture to see it through the lens. Click anywhere to let the night fall.
		</p>
		<button
			type="button"
			aria-pressed={night}
			class="border-foreground/20 bg-background/70 hover:bg-background focus-visible:ring-ring pointer-events-auto mt-4 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium backdrop-blur-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
			onclick={(event) => {
				event.stopPropagation();
				night = !night;
			}}
		>
			<span
				class="size-2 rounded-full {night ? 'bg-[#ffd15c]' : 'bg-foreground/30'}"
				aria-hidden="true"
			></span>
			Night
		</button>
	</div>
</PixelScene>
```

### Scenes

Alpine, meadow, sea and mountains, by day and by night.

```svelte
<script lang="ts">
	import { PixelScene, PIXEL_SCENES, type PixelSceneName } from "fancy-ui-svelte";

	// One instance whose `scene` changes: four live loops would cost four times the CPU.
	let scene = $state<PixelSceneName>("alpine");
	let night = $state(false);

	const label = (name: PixelSceneName) => name.charAt(0).toUpperCase() + name.slice(1);
</script>

<div class="flex w-full flex-col gap-3">
	<div class="flex flex-wrap items-center justify-between gap-3">
		<div
			class="border-foreground/15 inline-flex flex-wrap rounded-full border p-1"
			role="group"
			aria-label="Scene"
		>
			{#each PIXEL_SCENES as name (name)}
				<button
					type="button"
					aria-pressed={scene === name}
					class="focus-visible:ring-ring rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none {scene ===
					name
						? 'bg-foreground text-background'
						: 'text-muted-foreground hover:text-foreground'}"
					onclick={() => (scene = name)}
				>
					{label(name)}
				</button>
			{/each}
		</div>
		<button
			type="button"
			aria-pressed={night}
			class="border-foreground/15 hover:bg-foreground/5 focus-visible:ring-ring inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
			onclick={() => (night = !night)}
		>
			<span
				class="size-2 rounded-full {night ? 'bg-[#ffd15c]' : 'bg-foreground/30'}"
				aria-hidden="true"
			></span>
			Night
		</button>
	</div>
	<PixelScene
		{scene}
		{night}
		class="border-foreground/10 text-foreground h-[300px] w-full rounded-2xl border"
	/>
</div>
```

### Hours of low light

The scene owns its surface and ink, so a click carries the whole card on the wave. Opens on the sea at night; switch the HDR look to compare.

```svelte
<script lang="ts">
	import {
		PixelScene,
		PIXEL_SCENES,
		type PixelSceneHdrLook,
		type PixelSceneName,
	} from "fancy-ui-svelte";

	// The scene owns its surface and its ink for both themes, so the wave carries
	// the whole card from day to night: background, dots and halo. The copy follows
	// once the front has passed it.
	let scene = $state<PixelSceneName>("sea");
	let night = $state(true);
	// Two HDR looks to compare on an HDR display: calibrated levels, or the
	// prototype's hotter, wider-gamut rendering. Same picture on SDR screens.
	let hdrLook = $state<PixelSceneHdrLook>("vivid");
	const LOOKS: PixelSceneHdrLook[] = ["calibrated", "vivid"];

	const SURFACES = ["#f7f7f5", "#0b0b0c"] as const;
	const INKS = ["#141414", "#e9e9e6"] as const;
	const MUTED = ["#6b6b6b", "#9a9a9a"] as const;
</script>

<div class="flex w-full flex-col gap-3">
	<div class="flex flex-wrap items-center gap-2" role="group" aria-label="Scene">
		{#each PIXEL_SCENES as name (name)}
			<button
				type="button"
				aria-pressed={scene === name}
				class="focus-visible:ring-ring rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none {scene ===
				name
					? 'bg-foreground text-background border-foreground'
					: 'border-foreground/20 text-foreground hover:bg-foreground/5'}"
				onclick={() => (scene = name)}
			>
				{name}
			</button>
		{/each}
		<button
			type="button"
			aria-pressed={night}
			class="border-foreground/20 text-foreground hover:bg-foreground/5 focus-visible:ring-ring rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
			onclick={() => (night = !night)}
		>
			night / day
		</button>
		<span class="text-muted-foreground ml-auto text-xs">HDR</span>
		<div
			class="border-foreground/15 inline-flex rounded-full border p-0.5"
			role="group"
			aria-label="HDR look"
		>
			{#each LOOKS as look (look)}
				<button
					type="button"
					aria-pressed={hdrLook === look}
					class="focus-visible:ring-ring rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none {hdrLook ===
					look
						? 'bg-foreground text-background'
						: 'text-muted-foreground hover:text-foreground'}"
					onclick={() => (hdrLook = look)}
				>
					{look}
				</button>
			{/each}
		</div>
	</div>
	<PixelScene
		{scene}
		{night}
		{hdrLook}
		surfaces={SURFACES}
		inks={INKS}
		onclick={() => (night = !night)}
		class="h-[340px] w-full cursor-crosshair rounded-[14px]"
	>
		<div
			class="hours-copy pointer-events-none absolute top-9 left-9 max-w-[460px] pr-9"
			style:--copy={INKS[night ? 1 : 0]}
			style:--muted={MUTED[night ? 1 : 0]}
		>
			<p class="text-[11px] tracking-[0.28em] uppercase" style:color="var(--muted)">
				Issue 14 · Late autumn
			</p>
			<h3 class="mt-1.5 mb-2 text-[40px] leading-none font-semibold tracking-[-0.03em]">
				Hours of low light
			</h3>
			<p class="hidden text-sm sm:block" style:color="var(--muted)">
				Move the pointer over the picture to see it in colour. Click to switch night and day.
			</p>
		</div>
	</PixelScene>
</div>

<style>
	/* The copy sits far from the sun: it turns once the front has reached it. */
	.hours-copy,
	.hours-copy p {
		color: var(--copy);
		transition: color 0.3s ease 0.6s;
	}
</style>
```

### Interactive Playground

```svelte
<script lang="ts">
	import { PixelScene, type PixelSceneName } from "fancy-ui-svelte";
	import PropsPlayground from "$lib/components/PropsPlayground.svelte";
</script>

<PropsPlayground
	component="PixelScene"
	slug="pixel-scene"
	constraints={[
		"Give the host a height: the picture fills whatever box it gets.",
		"`night` is one-way: the component never changes it. Flip it from your own click, button or theme.",
		"Keep copy in the top-left corner, which every scene leaves open.",
	]}
	controls={[
		{
			key: "scene",
			type: "select",
			label: "Scene",
			options: [
				{ value: "alpine", label: "Alpine" },
				{ value: "meadow", label: "Meadow" },
				{ value: "sea", label: "Sea" },
				{ value: "mountains", label: "Mountains" },
			],
		},
		{ key: "night", type: "boolean", label: "Night" },
		{ key: "cell", type: "range", label: "Cell (px)", min: 4, max: 12, step: 1 },
		{ key: "dot", type: "range", label: "Dot (px)", min: 1, max: 12, step: 1 },
		{ key: "lens", type: "boolean", label: "Lens" },
		{ key: "lensRadius", type: "range", label: "Lens radius (cells)", min: 6, max: 40, step: 1 },
		{
			key: "ink",
			type: "select",
			label: "Ink",
			options: [
				{ value: "currentColor", label: "Text colour" },
				{ value: "#6b7383", label: "Slate" },
				{ value: "#b5402a", label: "Rust" },
				{ value: "#2f5a3a", label: "Pine" },
			],
		},
		{ key: "hdr", type: "boolean", label: "HDR" },
		{ key: "paused", type: "boolean", label: "Paused" },
	]}
	initialValues={{
		scene: "alpine",
		night: false,
		cell: 5,
		dot: 4,
		lens: true,
		lensRadius: 21,
		ink: "currentColor",
		hdr: true,
		paused: false,
	}}
>
	{#snippet preview(values)}
		<PixelScene
			class="border-foreground/10 text-foreground h-72 w-full rounded-xl border"
			scene={values.scene as PixelSceneName}
			night={values.night as boolean}
			cell={values.cell as number}
			dot={values.dot as number}
			lens={values.lens as boolean}
			lensRadius={values.lensRadius as number}
			ink={values.ink as string}
			hdr={values.hdr as boolean}
			paused={values.paused as boolean}
		/>
	{/snippet}
</PropsPlayground>
```
