diff --git a/.changeset/rain-backdrop-component.md b/.changeset/rain-backdrop-component.md
new file mode 100644
index 0000000..8585902
--- /dev/null
+++ b/.changeset/rain-backdrop-component.md
@@ -0,0 +1,7 @@
+---
+'@positronick/ui': minor
+---
+
+feat: add `RainBackdrop` — an animated rain background, drop-in sibling of `Backdrop`
+
+A canvas-based full-page rain layer that follows the theme ink (white rain on `machine`, black on `samaritan`), with a small accent-red fraction of drops and splash-particle bursts where drops hit the bottom edge. Props: `intensity` (0–1 rain amount), `accentRatio` (red-drop fraction, default 0.03), `speed` (fall multiplier), `splash` (impact bursts on/off). Honors `prefers-reduced-motion` with a static sparse frame, recolors live on theme change without a remount, and bounds its canvas/drop count so very tall pages degrade gracefully.
diff --git a/src/lib/components/RainBackdrop.reduced.svelte.test.ts b/src/lib/components/RainBackdrop.reduced.svelte.test.ts
new file mode 100644
index 0000000..d819afe
--- /dev/null
+++ b/src/lib/components/RainBackdrop.reduced.svelte.test.ts
@@ -0,0 +1,45 @@
+import { vi } from 'vitest';
+
+// Force the reduced-motion branch: a single static frame, no animation loop.
+vi.mock('../actions/reducedMotion.js', () => ({ reducedMotion: { current: true } }));
+
+import { render } from 'vitest-browser-svelte';
+import { createRawSnippet } from 'svelte';
+import { describe, expect, test } from 'vitest';
+import ThemedHarness from '../../test-support/ThemedHarness.svelte';
+import RainBackdrop from './RainBackdrop.svelte';
+
+const body = createRawSnippet(() => ({
+ render: () => `
STILL
`
+}));
+
+function canvas(): HTMLCanvasElement {
+ const el = document.querySelector('.pn-rain-backdrop__canvas');
+ if (!el) throw new Error('.pn-rain-backdrop__canvas not found');
+ return el;
+}
+
+function hasInk(): boolean {
+ const c = canvas();
+ if (c.width === 0 || c.height === 0) return false;
+ const ctx = c.getContext('2d');
+ if (!ctx) return false;
+ const data = ctx.getImageData(0, 0, c.width, c.height).data;
+ for (let i = 3; i < data.length; i += 4) if (data[i] > 0) return true;
+ return false;
+}
+
+describe('RainBackdrop (reduced motion)', () => {
+ test('paints a single static frame that never changes', async () => {
+ render(ThemedHarness, {
+ theme: 'machine',
+ Comp: RainBackdrop,
+ componentProps: { children: body, intensity: 1 }
+ });
+ await expect.poll(hasInk, { timeout: 3000 }).toBe(true);
+ const frame = canvas().toDataURL();
+ // If the rAF loop were running, drops would have moved well within 250ms.
+ await new Promise((r) => setTimeout(r, 250));
+ expect(canvas().toDataURL()).toBe(frame);
+ });
+});
diff --git a/src/lib/components/RainBackdrop.svelte b/src/lib/components/RainBackdrop.svelte
new file mode 100644
index 0000000..5c2757c
--- /dev/null
+++ b/src/lib/components/RainBackdrop.svelte
@@ -0,0 +1,284 @@
+
+
+
+
+
+
+
{@render children()}
+
+
+
diff --git a/src/lib/components/RainBackdrop.svelte.test.ts b/src/lib/components/RainBackdrop.svelte.test.ts
new file mode 100644
index 0000000..c832522
--- /dev/null
+++ b/src/lib/components/RainBackdrop.svelte.test.ts
@@ -0,0 +1,126 @@
+import { render } from 'vitest-browser-svelte';
+import { createRawSnippet } from 'svelte';
+import { describe, expect, test } from 'vitest';
+import ThemedHarness from '../../test-support/ThemedHarness.svelte';
+import RainBackdrop from './RainBackdrop.svelte';
+
+const body = createRawSnippet(() => ({
+ render: () => `IT'S RAINING
`
+}));
+
+function canvas(): HTMLCanvasElement {
+ const el = document.querySelector('.pn-rain-backdrop__canvas');
+ if (!el) throw new Error('.pn-rain-backdrop__canvas not found');
+ return el;
+}
+
+function layer(): HTMLElement {
+ const el = document.querySelector('.pn-rain-backdrop__layer');
+ if (!el) throw new Error('.pn-rain-backdrop__layer not found');
+ return el;
+}
+
+/** True once the animation has painted anything onto the (transparent) canvas. */
+function hasInk(): boolean {
+ const c = canvas();
+ if (c.width === 0 || c.height === 0) return false;
+ const ctx = c.getContext('2d');
+ if (!ctx) return false;
+ const data = ctx.getImageData(0, 0, c.width, c.height).data;
+ for (let i = 3; i < data.length; i += 4) if (data[i] > 0) return true;
+ return false;
+}
+
+/** Finds a painted pixel and reports whether it is light (white ink) or dark (black ink). */
+function inkShade(): 'light' | 'dark' | null {
+ const c = canvas();
+ const ctx = c.getContext('2d');
+ if (!ctx) return null;
+ const data = ctx.getImageData(0, 0, c.width, c.height).data;
+ for (let i = 0; i < data.length; i += 4) {
+ if (data[i + 3] > 50) return data[i] > 200 ? 'light' : data[i] < 60 ? 'dark' : null;
+ }
+ return null;
+}
+
+describe('RainBackdrop', () => {
+ test('renders content above the rain layer, layer is decorative and non-interactive', async () => {
+ const screen = render(ThemedHarness, {
+ theme: 'machine',
+ Comp: RainBackdrop,
+ componentProps: { children: body }
+ });
+ await expect.element(screen.getByTestId('rain-body')).toBeInTheDocument();
+ expect(document.querySelector('.pn-rain-backdrop__content')).not.toBeNull();
+ expect(layer().getAttribute('aria-hidden')).toBe('true');
+ expect(getComputedStyle(layer()).pointerEvents).toBe('none');
+ });
+
+ test('fills the viewport (does not collapse to content height)', () => {
+ render(ThemedHarness, {
+ theme: 'machine',
+ Comp: RainBackdrop,
+ componentProps: { children: body }
+ });
+ const root = document.querySelector('.pn-rain-backdrop');
+ if (!root) throw new Error('.pn-rain-backdrop not found');
+ expect(root.getBoundingClientRect().height).toBeGreaterThanOrEqual(window.innerHeight);
+ });
+
+ test('sizes the canvas backing store to the layer at (capped) devicePixelRatio', async () => {
+ render(ThemedHarness, {
+ theme: 'machine',
+ Comp: RainBackdrop,
+ componentProps: { children: body }
+ });
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ await expect.poll(() => canvas().width).toBe(Math.round(layer().clientWidth * dpr));
+ expect(canvas().height).toBe(Math.round(layer().clientHeight * dpr));
+ });
+
+ test('rain animates: frames are painted and change over time', async () => {
+ render(ThemedHarness, {
+ theme: 'machine',
+ Comp: RainBackdrop,
+ componentProps: { children: body, intensity: 1 }
+ });
+ await expect.poll(hasInk, { timeout: 3000 }).toBe(true);
+ const first = canvas().toDataURL();
+ await expect.poll(() => canvas().toDataURL() !== first, { timeout: 3000 }).toBe(true);
+ });
+
+ test('rain uses the theme ink: white on The Machine, recolors to black when the theme flips', async () => {
+ render(ThemedHarness, {
+ theme: 'machine',
+ Comp: RainBackdrop,
+ // High speed exercises bottom impacts (splash bursts) while we watch colors.
+ componentProps: { children: body, intensity: 1, speed: 3 }
+ });
+ await expect.poll(() => inkShade(), { timeout: 3000 }).toBe('light');
+ // ThemeProvider swaps data-theme in place (no remount) — the rain must follow.
+ document.querySelector('.pn-root')?.setAttribute('data-theme', 'samaritan');
+ await expect.poll(() => inkShade(), { timeout: 3000 }).toBe('dark');
+ });
+
+ test('intensity 0 paints no rain', async () => {
+ render(ThemedHarness, {
+ theme: 'machine',
+ Comp: RainBackdrop,
+ componentProps: { children: body, intensity: 0 }
+ });
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ await expect.poll(() => canvas().width).toBe(Math.round(layer().clientWidth * dpr));
+ // Give the loop a few frames, then confirm the canvas is still blank.
+ await new Promise((r) => setTimeout(r, 200));
+ expect(hasInk()).toBe(false);
+ });
+
+ test('edge props render and animate without error', async () => {
+ render(ThemedHarness, {
+ theme: 'samaritan',
+ Comp: RainBackdrop,
+ componentProps: { children: body, intensity: 1, accentRatio: 1, speed: 5, splash: false }
+ });
+ await expect.poll(hasInk, { timeout: 3000 }).toBe(true);
+ });
+});
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 52d17cb..15c3c28 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -35,6 +35,7 @@ export { default as PositronickMark } from './components/PositronickMark.svelte'
export { default as ProgressBar } from './components/ProgressBar.svelte';
export { default as Prose } from './components/Prose.svelte';
export { default as ProviderIcon } from './components/ProviderIcon.svelte';
+export { default as RainBackdrop } from './components/RainBackdrop.svelte';
export { default as RecDot } from './components/RecDot.svelte';
export { default as ScrollTopButton } from './components/ScrollTopButton.svelte';
export { default as SectionNav } from './components/SectionNav.svelte';
diff --git a/src/stories/RainBackdrop.stories.svelte b/src/stories/RainBackdrop.stories.svelte
new file mode 100644
index 0000000..e4c3833
--- /dev/null
+++ b/src/stories/RainBackdrop.stories.svelte
@@ -0,0 +1,75 @@
+
+
+{#snippet content()}
+
+
+
+
+
+{/snippet}
+
+
+
+ {@render content()}
+
+
+
+
+
+ {@render content()}
+
+
+
+
+
+ {#snippet template(args)}
+
+
+
+ {@render content()}
+
+
+ {/snippet}
+