diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d80511..0611530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ Format: [Keep a Changelog](https://keepachangelog.com/) · [SemVer](https://semver.org/). +## [1.1.0] — 2026-07-28 — "Surfaces" + +Twenty new components (31 → **51**) and a second primitive. The theme is surfaces you can +touch: light on a material, the pointer itself, and the page as it scrolls. + +### Added +- **`useShader()` primitive** — a raw **WebGL2** runtime for full-screen fragment shaders in + ~290 lines and **still zero runtime dependencies** (no `ogl`, no `three`). Draws a single + full-screen triangle from `gl_VertexID` (no buffers to allocate or leak) and owns the whole + lifecycle: DPR-clamped sizing via `ResizeObserver`, an `IntersectionObserver` that suspends the + loop off-screen, pause on tab blur, context-loss recovery, eased pointer input, uniform + declarations generated from the values you pass, and a single still frame for reduced-motion + users. Reports `supported: false` when WebGL2 is missing so components fall back to CSS + instead of a blank box. +- **Shaders & Light (6)** — `liquid-metal` (domain-warped FBM height field, normals from finite + differences, swept polish bands), `thin-film` (two-beam interference evaluated at three + wavelengths — real fringe order, not a hue rotation), `caustics` (folded coordinates + accumulating reciprocal distance), `halftone-gradient` (angled per-channel dot screens with + √-corrected dot area, or a computed 4×4 Bayer matrix), `volumetric-rays` (radial scattering + integral with decay normalised by sample count), `curl-flow` (FBM as a stream function; the + velocity is the perpendicular of its gradient, so the field is divergence-free by construction). +- **Cursor & Pointer (5)** — `inertia-cursor`, `elastic-cursor` (stretches along its velocity + vector with conserved area), `image-trail` (distance-thresholded emission, so spacing is even + at any speed), `pixel-trail` (sub-cell path walking, so a fast flick still lights a continuous + line), `crosshair` (point-to-rectangle snapping with a live coordinate readout). +- **Scroll-Driven (4)** — `scroll-stack`, `scroll-velocity`, `parallax-layers`, `scroll-scene` + (pins a section and exposes scrub progress via render prop, a CSS custom property, and a + per-frame callback). +- **Components (5)** — `card-swap`, `gooey-nav`, `bento-grid` (one pointer listener for the whole + grid, published to tiles as inherited custom properties), `elastic-slider` (exponentially damped + overshoot, full `role="slider"` keyboard contract), `circular-gallery` (drag converted through + arc length, so tracking is 1:1 at any radius). + +### Changed +- `BentoGrid` gains a `rowHeight` prop; the row height was previously hardcoded at 140px, which + made the component unusable in any frame shorter than two rows. +- The reduced-motion test is now **transitive**: a component satisfies the contract either + directly or through a primitive that does, so the shaders inherit the guarantee from + `useShader` rather than restating it. +- The playground gives scroll-driven components a full-height section of their own — a 200px + preview tile can't demonstrate something driven by the page scroller. + +### Fixed +- The CLI's `list` command iterated a hardcoded array of four categories, so components in any + newer category were silently omitted from its output. Both the CLI and the MCP server now derive + the category set from the registry. +- The MCP integration test asserted a literal component count, which fails on every addition and + trains you to bump the number instead of reading the failure. Expected counts now come from + `registry/meta.json`. + ## [1.0.0] — 2026-06-18 — "Re-architecture" A ground-up rebuild. ReactOmega is now an **AI-native React component registry** focused on diff --git a/README.md b/README.md index 3a1ff50..c7d67f7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **motion & interaction** components you copy into your project — installable in one line by **humans _and_ AI agents**. -![Version](https://img.shields.io/badge/version-1.0.0-7c5cff) +![Version](https://img.shields.io/badge/version-1.1.0-7c5cff) ![License](https://img.shields.io/badge/license-MIT-blue) ![Deps](https://img.shields.io/badge/runtime%20deps-none-3c873a) ![A11y](https://img.shields.io/badge/reduced--motion-safe-22d3ee) @@ -60,7 +60,7 @@ npx -y github:Edwson/ReactOmega list --category text # filter by category ## Run the playground -**Live:** **https://edwson.github.io/ReactOmega/** — every one of the 31 components rendered live, +**Live:** **https://edwson.github.io/ReactOmega/** — every one of the 51 components rendered live, with a copy-paste install command on each card. The playground source lives in [`web/`](web) (Next.js + Tailwind). To run it locally: @@ -70,23 +70,40 @@ npm install npm run dev # → http://localhost:3000 ``` -## Components (v1.0) — 31 +## Components (v1.1) — 51 -**Text Animations** (novel + deeply customizable kinetic typography) +**Text Animations** (10) — novel + deeply customizable kinetic typography `split-text` · `shiny-text` · `gradient-text` · `count-up` · `variable-proximity` (per-letter variable-font axes by pointer distance) · `magnetic-text` (each letter springs to the pointer) · `gravity-text` (letters fall, bounce & reassemble with real gravity) · `elastic-text` (per-letter jelly squash/stretch) · `scramble-text` (configurable decode) · `text-reveal` (scroll-linked word-by-word brightening) -**Interaction & Motion** +**Interaction & Motion** (6) `magnetic` · `spotlight-card` · `tilt-card` · `holographic-card` (iridescent foil + 3D tilt) · `click-spark` · `dot-grid` -**Components** -`star-border` · `dock` · `marquee` - -**Physics & Art** (real physics — tactile, designed, never cosmic) — 12 +**Cursor & Pointer** (5) — *new in 1.1* +`inertia-cursor` (exact dot + spring-lagged ring, difference-blended) · `elastic-cursor` (stretches +along its velocity vector, area conserved) · `image-trail` (distance-thresholded, so spacing is even +at any speed) · `pixel-trail` (sub-cell path walking — a fast flick still lights a continuous line) · +`crosshair` (point-to-rectangle snapping + live coordinate readout) + +**Scroll-Driven** (4) — *new in 1.1* +`scroll-stack` (sticky deck, continuous depth) · `scroll-velocity` (skews with scroll velocity, +frame-rate independent) · `parallax-layers` (progress normalised to the section's own centre) · +`scroll-scene` (pins a section, exposes 0→1 scrub progress via render prop, CSS variable, or callback) + +**Shaders & Light** (6) — *new in 1.1* — **hand-written GLSL on raw WebGL2, still zero deps** +`liquid-metal` (domain-warped FBM, normals from finite differences, swept polish bands) · +`thin-film` (two-beam interference at three wavelengths — real fringe order, not a hue rotation) · +`caustics` (folded coordinates accumulating reciprocal distance) · `halftone-gradient` (angled +per-channel dot screens with √-corrected dot area, or a computed 4×4 Bayer matrix) · +`volumetric-rays` (radial scattering integral, decay normalised by sample count) · `curl-flow` +(FBM as a stream function; velocity is the perpendicular of its gradient, so the field is +divergence-free by construction) + +**Physics & Art** (12) — real physics — tactile, designed, never cosmic `spring-mesh` (press an elastic lattice, waves ripple & settle) · `cloth` (a verlet fabric you grab & wave) · `metaballs` (gooey fluid that merges around the pointer) · `rope` (a verlet rope you grab & swing) · `pendulum-wave` (staggered-length pendulums drift in & out of phase) · `newtons-cradle` @@ -96,6 +113,28 @@ reflects) · `gravity-wells` (particles orbit the pointer with luminous trails) `plucked-string` (pluck it, it vibrates with a decaying wave) · `falling-sand` (pour colored grains that pile into mounds) +**Components** (8) +`star-border` · `dock` · `marquee` · `card-swap` (3D deck, every slot a pure function of depth) · +`gooey-nav` (indicator stretches in flight, fused by an SVG goo filter) · `bento-grid` (one pointer +listener for the whole grid, shared with tiles as inherited custom properties) · `elastic-slider` +(exponentially damped overshoot, full `role="slider"` keyboard contract) · `circular-gallery` +(drag converted through arc length, so tracking is 1:1 at any radius) + +### Shaders without a shader library + +The six shader components share a `useShader()` primitive — a raw WebGL2 runtime in ~290 lines +with **no `ogl`, no `three`, and no new dependencies**. It draws one full-screen triangle from +`gl_VertexID` (nothing to allocate, nothing to leak) and owns DPR-clamped sizing, suspension while +off-screen, pause on tab blur, context-loss recovery, eased pointer input, and the single still +frame reduced-motion users get. When WebGL2 is unavailable it reports `supported: false` so the +component renders a CSS fallback rather than a blank box. + +```tsx +import { LiquidMetal } from "@/components/reactomega/liquid-metal"; + +
; +``` + Each ships a structured contract in the registry: description, tags, npm + primitive dependencies, and the source. Browse [`registry.json`](registry.json) or any [`public/r/.json`](public/r). diff --git a/cli/index.mjs b/cli/index.mjs index 313b913..47219b6 100644 --- a/cli/index.mjs +++ b/cli/index.mjs @@ -5,7 +5,7 @@ * Copies a component (and its primitive dependencies) straight into your project, * shadcn-style: you own the code. Zero runtime dependencies (Node 18+ built-ins). * - * npx reactomega list [--category text|interaction|components] + * npx reactomega list [--category text|interaction|cursor|scroll|shader|physics|components] * npx reactomega add split-text magnetic [--cwd .] [--registry ] [--dry] * * --registry defaults to the published registry on jsDelivr; pass a local path @@ -62,7 +62,11 @@ async function cmdList() { const category = flag("category"); const comps = reg.items.filter((i) => i.type === "registry:component" && (!category || i.category === category)); console.log(color("bold", `\nReactOmega v${reg.version} — ${comps.length} components\n`)); - const cats = ["text", "interaction", "components", "physics"]; + // Preferred reading order, then anything else the registry knows about — a + // hardcoded list would silently hide whole categories as the registry grows. + const PREFERRED = ["text", "interaction", "cursor", "scroll", "shader", "physics", "components"]; + const present = [...new Set(comps.map((i) => i.category))]; + const cats = [...PREFERRED.filter((c) => present.includes(c)), ...present.filter((c) => !PREFERRED.includes(c))]; for (const cat of cats) { const inCat = comps.filter((i) => i.category === cat); if (!inCat.length) continue; @@ -119,7 +123,7 @@ async function cmdAdd() { else { console.log(`reactomega — copy premium React components into your project - reactomega list [--category text|interaction|components] + reactomega list [--category text|interaction|cursor|scroll|shader|physics|components] reactomega add [--cwd .] [--registry ] [--dry] Registry: ${SRC} diff --git a/llms.txt b/llms.txt index 5dfa3da..9e50fc6 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # ReactOmega -> AI-native React component registry — premium, accessible, reduced-motion-safe motion & interaction components. v1.0.0. Install any component in one line via the ReactOmega CLI, shadcn, or an MCP server. Source of truth: /registry.json. +> AI-native React component registry — premium, accessible, reduced-motion-safe motion & interaction components. v1.1.0. Install any component in one line via the ReactOmega CLI, shadcn, or an MCP server. Source of truth: /registry.json. ## Install a component ``` @@ -29,10 +29,26 @@ Every component is a self-contained file copied into your project (you own the c - `dot-grid` — An interactive dot field that lights up and swells around the pointer. A modern, non-cosmic backdrop. - `holographic-card` — An iridescent holo-foil card: a rainbow sheen and bright glare track the pointer with a color-dodge blend, over a subtle 3D tilt. Configurable tilt, intensity, and foil colors. -## Components -- `star-border` — Two soft glints travel continuously around the border of any element. Pure CSS. -- `dock` — A macOS-style magnifying dock; items swell based on pointer distance. Renders as a toolbar. -- `marquee` — A seamless infinite scroller (horizontal or vertical) with edge fade and pause-on-hover. +## Cursor & Pointer +- `inertia-cursor` — Replaces the native cursor with an exact dot plus a ring that lags on a spring, growing over anything matching a selector. Hover is delegated from the document so late-mounted elements work, and difference blending keeps it visible on any background. +- `elastic-cursor` — A gooey cursor that stretches along its own velocity vector and squashes perpendicular so its area is conserved exactly, rotating into the direction of travel and settling back to a circle at rest. Optional trailing blobs are welded together by an SVG goo filter. +- `image-trail` — Drops images along the pointer's path as it crosses a container, each fading and scaling out. Emission is thresholded by distance travelled rather than by event, with the remainder carried between events, so spacing stays even at any speed. +- `pixel-trail` — A canvas grid whose cells ignite under the pointer and decay exponentially into a dissolving comet trail. The path between the previous and current position is walked at sub-cell steps, so a fast flick still lights a continuous line. +- `crosshair` — A precision overlay: full-bleed hairlines, a centre reticle and a live monospace coordinate readout that snap to nearby targets. Distance is measured point-to-rectangle so hovering a target always locks, and one smoothstepped scalar drives the whole engage. + +## Scroll-Driven +- `scroll-stack` — Cards that stack into a deck as you scroll, pinned with native sticky positioning. Each card's depth is the running sum of how far the later cards have travelled, so the scale, dim and blur are continuous rather than stepped, and the front card always stays clean. +- `scroll-velocity` — A seamless row that drifts at a base speed but is driven by scroll velocity, skewing as it lurches and easing back to baseline when you stop. Velocity is smoothed with a time-constant decay, so the feel is identical at 60Hz and 120Hz. +- `parallax-layers` — Depth layers that drift at different rates as a section crosses the viewport, with optional scale and blur on distant layers. Progress is normalised against the section's own centre, so the composition is neutral exactly when the section is centred. +- `scroll-scene` — Pins a section and exposes its scrub progress from 0 to 1 through a render prop, plus a live CSS custom property and a per-frame callback. Travel is derived from the measured spacer and pinned heights, so there is no viewport constant to get wrong. + +## Shaders & Light (WebGL2) +- `liquid-metal` — A molten chrome surface that flows, catches light and dents away from the pointer. Two rounds of domain-warped FBM drive a height field; the normal is taken from finite differences each frame and lit with diffuse, specular and swept polish bands. +- `thin-film` — Iridescent soap-film sheen computed from real thin-film interference: phase is evaluated at three representative RGB wavelengths over a wobbling thickness field, so the fringe order is physical rather than a hue rotation. The pointer thins the film and shifts the bands. +- `caustics` — Underwater light caustics: a coordinate folded back on itself several times, each pass accumulating reciprocal distance, then gamma-crushed so only the focus filaments survive. The pointer disturbs the water above. +- `halftone-gradient` — A drifting multi-stop gradient rendered through a print screen — either angled per-channel dots with radius proportional to the square root of tone, or a computed 4x4 ordered Bayer matrix. The screen opens up under the pointer. +- `volumetric-rays` — God rays from a pointer-driven light source, integrated the classic way: march back toward the light sampling a drifting haze field and attenuating each step, with decay normalised by sample count so quality and brightness stay independent. +- `curl-flow` — Silk-like flowing bands from a curl-noise field. FBM acts as a stream function and the velocity is the perpendicular of its gradient, so the field is divergence-free by construction; coordinates advect along it and shade as anisotropic streamlines. The pointer injects a vortex. ## Physics & Art - `spring-mesh` — An elastic lattice: every node is sprung to rest and coupled to its neighbors, so pressing the pointer sends real waves rippling outward that settle naturally. @@ -48,9 +64,20 @@ Every component is a self-contained file copied into your project (you own the c - `plucked-string` — A string pinned at both ends that you grab and pluck, then watch vibrate with a realistic decaying wave driven by neighbor-averaging physics. - `falling-sand` — A falling-sand cellular automaton where dragging the pointer pours colored grains that fall and slide diagonally into realistic piling mounds. +## Components +- `star-border` — Two soft glints travel continuously around the border of any element. Pure CSS. +- `dock` — A macOS-style magnifying dock; items swell based on pointer distance. Renders as a toolbar. +- `marquee` — A seamless infinite scroller (horizontal or vertical) with edge fade and pause-on-hover. +- `card-swap` — A 3D deck where the front card peels away and the stack advances on an interval or on click. Every slot is a pure function of a card's distance from the front, so CSS transitions do all the interpolation; it pauses on hover and focus and announces changes politely. +- `gooey-nav` — A pill navigation whose liquid indicator stretches as it travels between items and settles into a single pill, fused by an SVG goo filter. Real buttons with roving tabindex and arrow-key movement; labels stay outside the filter so text remains crisp. +- `bento-grid` — A bento layout where the grid owns the only pointer listener and publishes shared coordinates as inherited custom properties, so the spotlight is pure CSS and continuous across gutters no matter how many tiles there are. Tiles add proximity glow and a subtle tilt. +- `elastic-slider` — A slider whose rail stretches like rubber past either end with exponentially diminishing overshoot, then springs back on release. Implements the full slider ARIA contract with arrow, page, home and end keys, pointer capture and touch support. +- `circular-gallery` — Items arranged around a horizontal cylinder that you drag to spin, with inertia on release and optional snapping to the nearest item. Drag is converted through arc length so tracking is one-to-one at any radius, and items past the horizon dim and stop taking pointer events. + ## Primitives (added automatically as dependencies) - `utils` — Tailwind class merge helper used by every component. - `use-prefers-reduced-motion` — React hook that tracks the user's reduced-motion preference; motion components degrade gracefully when it is true. +- `use-shader` — A dependency-free raw WebGL2 runtime for full-screen fragment shaders: DPR-clamped sizing, suspension while off-screen, context-loss recovery, eased pointer input, auto-generated uniform declarations, and a single still frame for reduced-motion users. ## Machine-readable & MCP - Registry index: [/registry.json](registry.json) — every component with deps + file targets. diff --git a/mcp/server.mjs b/mcp/server.mjs index 2bfa29a..a261999 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -14,6 +14,9 @@ import { loadRegistry } from "./registry-core.mjs"; const reg = loadRegistry(); const VERSION = reg.version; +// Derived from the registry, not written out by hand — adding a category to +// meta.json must never require editing the MCP schema to match. +const CATEGORIES = [...new Set(reg.listComponents({}).map((c) => c.category))].sort(); const argv = process.argv.slice(2); if (argv.includes("--version") || argv.includes("-v")) { @@ -40,7 +43,7 @@ server.registerTool( title: "List ReactOmega components", description: "List every component (name, title, category, description, tags). Optionally filter by category or a keyword query.", inputSchema: { - category: z.enum(["text", "interaction", "components", "physics"]).optional().describe("Restrict to one category."), + category: z.enum(CATEGORIES).optional().describe(`Restrict to one category: ${CATEGORIES.join(", ")}.`), query: z.string().optional().describe("Case-insensitive keyword across name/title/description/tags."), }, }, diff --git a/mcp/test-mcp.mjs b/mcp/test-mcp.mjs index 381e298..0165358 100644 --- a/mcp/test-mcp.mjs +++ b/mcp/test-mcp.mjs @@ -4,12 +4,19 @@ * the SDK client and exercises every tool the way an agent would. Requires the SDK. * Run: node mcp/test-mcp.mjs (exit 0 = pass) */ +import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const here = dirname(fileURLToPath(import.meta.url)); +// Expected counts come from the single source of truth, never a literal — a test +// that hardcodes "31" fails every time a component is added, which trains people +// to bump the number instead of reading the failure. +const meta = JSON.parse(readFileSync(join(here, "..", "registry/meta.json"), "utf8")); +const TOTAL = Object.keys(meta.components).length; +const inCat = (c) => Object.values(meta.components).filter((x) => x.category === c).length; let fails = 0; const ok = (c, m) => { if (c) console.log(" ✓ " + m); else { console.error(" ✗ " + m); fails++; } }; const sc = (r) => r.structuredContent || JSON.parse(r.content[0].text); @@ -25,13 +32,21 @@ try { ok(tools.length === 3 && ["add_component", "get_component", "list_components"].every((n) => tools.includes(n)), "advertises 3 tools"); const list = sc(await client.callTool({ name: "list_components", arguments: {} })); - ok(Array.isArray(list.results) && list.results.length === 31, `list_components returns 31 (got ${list.results?.length})`); + ok(Array.isArray(list.results) && list.results.length === TOTAL, `list_components returns every component (${TOTAL}, got ${list.results?.length})`); const filtered = sc(await client.callTool({ name: "list_components", arguments: { category: "text" } })); - ok(filtered.results.length === 10 && filtered.results.every((c) => c.category === "text"), "list_components filters by category (10 text)"); + ok(filtered.results.length === inCat("text") && filtered.results.every((c) => c.category === "text"), `list_components filters by category (${inCat("text")} text)`); const physics = sc(await client.callTool({ name: "list_components", arguments: { category: "physics" } })); - ok(physics.results.length === 12 && physics.results.every((c) => c.category === "physics"), "list_components returns 12 physics components"); + ok(physics.results.length === inCat("physics") && physics.results.every((c) => c.category === "physics"), `list_components returns ${inCat("physics")} physics components`); + + const shader = sc(await client.callTool({ name: "list_components", arguments: { category: "shader" } })); + ok(shader.results.length === inCat("shader") && shader.results.every((c) => c.category === "shader"), `list_components returns ${inCat("shader")} shader components`); + + const webgl = sc(await client.callTool({ name: "add_component", arguments: { name: "liquid-metal" } })); + const webglFiles = (webgl.files || []).map((f) => f.target); + ok(webglFiles.includes("hooks/use-shader.ts") && webglFiles.includes("hooks/use-prefers-reduced-motion.ts"), + "add_component resolves the WebGL primitive chain transitively"); const get = sc(await client.callTool({ name: "get_component", arguments: { name: "split-text" } })); ok(get.name === "split-text" && get.files?.[0]?.content?.includes("export function SplitText"), "get_component returns the source"); diff --git a/package.json b/package.json index 0575c85..503016d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "reactomega", - "version": "1.0.0", - "description": "The AI-native React component registry — premium, accessible, reduced-motion-safe motion, interaction & physics components, installable by humans and AI agents via a CLI, shadcn, or an MCP server.", + "version": "1.1.0", + "description": "The AI-native React component registry \u2014 premium, accessible, reduced-motion-safe motion, interaction & physics components, installable by humans and AI agents via a CLI, shadcn, or an MCP server.", "keywords": [ "react", "components", @@ -16,7 +16,12 @@ "ai-native", "accessibility", "reduced-motion", - "copy-paste" + "copy-paste", + "webgl", + "shader", + "glsl", + "cursor", + "scroll" ], "license": "MIT", "author": "Ed Chen (https://www.edwson.com)", diff --git a/public/r/bento-grid.json b/public/r/bento-grid.json new file mode 100644 index 0000000..6fae85e --- /dev/null +++ b/public/r/bento-grid.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "bento-grid", + "type": "registry:component", + "title": "Bento Grid", + "description": "A bento layout where the grid owns the only pointer listener and publishes shared coordinates as inherited custom properties, so the spotlight is pure CSS and continuous across gutters no matter how many tiles there are. Tiles add proximity glow and a subtle tilt.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/bento-grid.tsx", + "type": "registry:component", + "target": "components/reactomega/bento-grid.tsx", + "content": "\"use client\";\n\nimport { createContext, useContext, useEffect, useId, useMemo, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\n/** Called once per animation frame with grid-relative pointer coordinates. */\ntype TileFrame = (gx: number, gy: number, inside: boolean) => void;\n\ninterface BentoContextValue {\n register: (fn: TileFrame) => () => void;\n columns: number;\n tilt: number;\n spotlight: boolean;\n reduced: boolean;\n}\n\nconst BentoContext = createContext({\n register: () => () => {},\n columns: 4,\n tilt: 0,\n spotlight: false,\n reduced: true,\n});\n\n/** px beyond a tile's edge at which its border glow reaches zero. */\nconst GLOW_FALLOFF = 220;\n\nexport interface BentoGridProps extends React.HTMLAttributes {\n /** Extra classes for the grid. @default undefined */\n className?: string;\n /** `BentoTile` children. @default undefined */\n children?: React.ReactNode;\n /** Columns at the large breakpoint — halved at `sm`, single column below. @default 4 */\n columns?: number;\n /** Gutter in px. @default 16 */\n gap?: number;\n /** Minimum height of each grid row in px. @default 140 */\n rowHeight?: number;\n /** Track the pointer with a radial spotlight that crosses tile boundaries. @default true */\n spotlight?: boolean;\n /** Spotlight colour at its centre. @default \"rgba(124,92,255,.20)\" */\n spotlightColor?: string;\n /** Maximum per-tile tilt in degrees, 0 disables. @default 6 */\n tilt?: number;\n}\n\n/**\n * BentoGrid — a responsive bento layout that behaves like one surface rather\n * than a pile of independent cards. The grid owns the *only* pointer listener:\n * on each animation frame it reads its own live `getBoundingClientRect()` and\n * publishes grid-relative coordinates as the inherited custom properties\n * `--ro-gx` / `--ro-gy`. Because custom properties cascade, every tile's\n * spotlight is pure CSS — each tile subtracts its own `offsetLeft` / `offsetTop`\n * (`--ro-ox` / `--ro-oy`) inside `calc()`, so a single radial gradient appears\n * continuous across gutters with zero per-tile listeners. Effects that CSS\n * cannot express from those variables — the 3D tilt and the proximity border\n * glow — are driven by the same frame: tiles subscribe a callback and the grid\n * fans out to them, writing `style.transform` directly instead of through React\n * state. Only transforms and custom properties are written, so layout stays\n * clean and the per-frame offset reads stay cheap.\n *\n * Reduced motion detaches the listener entirely: no tracking, no tilt, no\n * per-frame work. Tiles fall back to a static hover border so they still read\n * as interactive, and links stay links.\n */\nexport function BentoGrid({\n className,\n children,\n columns = 4,\n gap = 16,\n rowHeight = 140,\n spotlight = true,\n spotlightColor = \"rgba(124,92,255,.20)\",\n tilt = 6,\n style,\n ...rest\n}: BentoGridProps) {\n const reduced = usePrefersReducedMotion();\n const gridRef = useRef(null);\n const subs = useRef>(new Set());\n const cls = \"ro-bento-\" + useId().replace(/[^a-zA-Z0-9-]/g, \"\");\n\n const ctx = useMemo(\n () => ({\n register: (fn: TileFrame) => {\n subs.current.add(fn);\n return () => {\n subs.current.delete(fn);\n };\n },\n columns: Math.max(1, Math.round(columns)),\n tilt: reduced ? 0 : Math.max(0, tilt),\n spotlight: spotlight && !reduced,\n reduced,\n }),\n [columns, tilt, spotlight, reduced],\n );\n\n useEffect(() => {\n const grid = gridRef.current;\n if (!grid || reduced || (!spotlight && tilt <= 0)) return;\n\n let raf = 0;\n let cx = 0;\n let cy = 0;\n let inside = false;\n\n const frame = () => {\n raf = 0;\n // Live rect every frame — the page scrolls and a cached rect goes stale.\n const r = grid.getBoundingClientRect();\n const gx = inside ? cx - r.left : -9999;\n const gy = inside ? cy - r.top : -9999;\n grid.style.setProperty(\"--ro-gx\", `${gx}px`);\n grid.style.setProperty(\"--ro-gy\", `${gy}px`);\n subs.current.forEach((fn) => fn(gx, gy, inside));\n };\n const schedule = () => {\n if (!raf) raf = requestAnimationFrame(frame);\n };\n const onMove = (e: PointerEvent) => {\n cx = e.clientX;\n cy = e.clientY;\n inside = true;\n schedule();\n };\n const onLeave = () => {\n inside = false;\n schedule();\n };\n\n grid.addEventListener(\"pointermove\", onMove, { passive: true });\n grid.addEventListener(\"pointerleave\", onLeave);\n return () => {\n cancelAnimationFrame(raf);\n grid.removeEventListener(\"pointermove\", onMove);\n grid.removeEventListener(\"pointerleave\", onLeave);\n };\n }, [reduced, spotlight, tilt]);\n\n const cols = Math.max(1, Math.round(columns));\n const sm = Math.min(2, cols);\n\n return (\n \n \n {children}\n \n );\n}\n\nexport interface BentoTileProps extends React.HTMLAttributes {\n /** Extra classes for the tile. @default undefined */\n className?: string;\n /** Tile content. @default undefined */\n children?: React.ReactNode;\n /** Columns to span at the large breakpoint, clamped to the grid width. @default 1 */\n colSpan?: number;\n /** Rows to span at the large breakpoint. @default 1 */\n rowSpan?: number;\n /** Render as a link. Tiles with an href are real anchors and keyboard reachable. @default undefined */\n href?: string;\n}\n\n/**\n * BentoTile — one cell of a `BentoGrid`. Its span collapses to a single column\n * on small screens (a 2-wide tile inside a 1-column grid would otherwise create\n * a phantom track), widens to at most two at `sm`, and takes its full span at\n * `lg`. It subscribes to the grid's shared pointer frame to write its own tilt\n * transform and `--ro-glow` proximity value, and exposes its `offsetLeft` /\n * `offsetTop` as custom properties so the grid-wide spotlight can be positioned\n * from CSS alone. With an `href` it renders an ``; without one it is a plain\n * container, never a div pretending to be a button.\n */\nexport function BentoTile({\n className,\n children,\n colSpan = 1,\n rowSpan = 1,\n href,\n style,\n ...rest\n}: BentoTileProps) {\n const { register, columns, tilt, spotlight, reduced } = useContext(BentoContext);\n const ref = useRef(null);\n const cls = \"ro-tile-\" + useId().replace(/[^a-zA-Z0-9-]/g, \"\");\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const sync = () => {\n el.style.setProperty(\"--ro-ox\", `${el.offsetLeft}px`);\n el.style.setProperty(\"--ro-oy\", `${el.offsetTop}px`);\n };\n sync();\n if (reduced) return;\n\n const onFrame: TileFrame = (gx, gy, inside) => {\n const ox = el.offsetLeft;\n const oy = el.offsetTop;\n const w = el.offsetWidth || 1;\n const h = el.offsetHeight || 1;\n el.style.setProperty(\"--ro-ox\", `${ox}px`);\n el.style.setProperty(\"--ro-oy\", `${oy}px`);\n\n const dx = Math.max(ox - gx, 0, gx - (ox + w));\n const dy = Math.max(oy - gy, 0, gy - (oy + h));\n const dist = Math.hypot(dx, dy);\n el.style.setProperty(\"--ro-glow\", inside ? Math.max(0, 1 - dist / GLOW_FALLOFF).toFixed(3) : \"0\");\n\n if (tilt > 0) {\n if (inside && dist === 0) {\n const px = (gx - ox) / w;\n const py = (gy - oy) / h;\n el.style.transform = `perspective(900px) rotateX(${((0.5 - py) * 2 * tilt).toFixed(2)}deg) rotateY(${(\n (px - 0.5) *\n 2 *\n tilt\n ).toFixed(2)}deg)`;\n } else {\n el.style.transform = \"perspective(900px) rotateX(0deg) rotateY(0deg)\";\n }\n }\n };\n const off = register(onFrame);\n return () => off();\n }, [register, tilt, reduced]);\n\n const c = Math.max(1, Math.min(Math.round(colSpan), Math.max(1, columns)));\n const sm = Math.min(c, 2);\n const r = Math.max(1, Math.round(rowSpan));\n\n const shell = cn(\n \"group relative isolate block overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5\",\n \"outline-none backdrop-blur will-change-transform\",\n \"focus-visible:ring-2 focus-visible:ring-[#7c5cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#06060a]\",\n reduced && \"transition-colors hover:border-white/25\",\n cls,\n className,\n );\n\n const spans = (\n \n );\n\n const inner = (\n <>\n {spotlight && (\n \n )}\n {!reduced && (\n \n )}\n
{children}
\n \n );\n\n const inlineStyle: React.CSSProperties = {\n transition: reduced ? undefined : \"transform .18s cubic-bezier(.2,.7,.3,1)\",\n ...style,\n };\n\n if (href) {\n return (\n <>\n {spans}\n )}\n href={href}\n ref={(el) => {\n ref.current = el;\n }}\n className={shell}\n style={inlineStyle}\n >\n {inner}\n
\n \n );\n }\n\n return (\n <>\n {spans}\n {\n ref.current = el;\n }}\n className={shell}\n style={inlineStyle}\n >\n {inner}\n \n \n );\n}\n" + } + ], + "meta": { + "category": "components", + "tags": [ + "components", + "bento", + "grid", + "spotlight", + "tilt" + ] + } +} diff --git a/public/r/card-swap.json b/public/r/card-swap.json new file mode 100644 index 0000000..2aca123 --- /dev/null +++ b/public/r/card-swap.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "card-swap", + "type": "registry:component", + "title": "Card Swap", + "description": "A 3D deck where the front card peels away and the stack advances on an interval or on click. Every slot is a pure function of a card's distance from the front, so CSS transitions do all the interpolation; it pauses on hover and focus and announces changes politely.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/card-swap.tsx", + "type": "registry:component", + "target": "components/reactomega/card-swap.tsx", + "content": "\"use client\";\n\nimport { Children, useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface CardSwapProps extends Omit, \"onChange\" | \"children\"> {\n /** Cards to stack. Each child becomes one card face. */\n children: React.ReactNode;\n /** Extra classes for the deck container. @default undefined */\n className?: string;\n /** ms the front card holds before the deck advances. @default 3400 */\n interval?: number;\n /** Floor height of the stage in px — the deck needs room for its depth. @default 280 */\n minHeight?: number;\n /** px each card behind the front is nudged right (and up). @default 26 */\n offset?: number;\n /** px of Z distance between neighbouring cards. @default 62 */\n depth?: number;\n /** Degrees of Y-rotation added per card of depth. @default 5 */\n rotation?: number;\n /** Advance the deck on a timer. @default true */\n autoplay?: boolean;\n /** Called with the new front-card index whenever the deck moves. @default undefined */\n onChange?: (index: number) => void;\n}\n\n/** How many cards behind the front stay visible (and get reserved space). */\nconst VISIBLE_LAYERS = 3;\n/** ms the outgoing card spends flying forward before the deck re-indexes. */\nconst PEEL_MS = 260;\n/** ms every card takes to glide to its new slot. */\nconst SETTLE_MS = 520;\n\n/**\n * CardSwap — a 3D deck where cards recede into the distance and the front one\n * peels away to reveal the next. Every card sits in the same absolutely\n * positioned stage under a shared `perspective`; its slot is derived purely\n * from `d`, its distance from the front, as\n * `translate3d(d·offset, -d·offset·0.55, -d·depth) rotateY(d·rotation)`, so the\n * whole deck is one pure function of the active index and CSS transitions do\n * the interpolation. Advancing runs in two phases: the outgoing card is thrown\n * forward on the top z-layer for `PEEL_MS`, then the index advances and it\n * drops to the back z-layer and slides home behind the stack. The stage is\n * inset by the total stack offset so cards never clip out of the container,\n * whatever children you pass.\n *\n * Accessibility: every card is a real `\n );\n })}\n \n
\n {n ? `Card ${active + 1} of ${n}` : \"Empty deck\"}\n
\n \n );\n}\n" + } + ], + "meta": { + "category": "components", + "tags": [ + "components", + "cards", + "3d", + "carousel" + ] + } +} diff --git a/public/r/caustics.json b/public/r/caustics.json new file mode 100644 index 0000000..b2a8b69 --- /dev/null +++ b/public/r/caustics.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "caustics", + "type": "registry:component", + "title": "Caustics", + "description": "Underwater light caustics: a coordinate folded back on itself several times, each pass accumulating reciprocal distance, then gamma-crushed so only the focus filaments survive. The pointer disturbs the water above.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/caustics.tsx", + "type": "registry:component", + "target": "components/reactomega/caustics.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface CausticsProps {\n className?: string;\n /** Colour of the focused light. @default \"#a9c4ff\" */\n color?: string;\n /** Colour of the unlit floor. @default \"#06060a\" */\n background?: string;\n /** Brightness of the filaments. @default 1 */\n intensity?: number;\n /** Zoom of the caustic web — higher packs more cells in. @default 1.35 */\n scale?: number;\n /** Speed of the water above. @default 1 */\n speed?: number;\n}\n\n/**\n * Caustics — the net of focused light that a rippling water surface throws onto\n * the floor beneath it.\n *\n * Built the way caustics are actually cheap to fake: a coordinate is folded\n * back on itself five times, each pass displacing it by sines and cosines of\n * its own components at a different temporal rate, and each pass accumulates\n * the reciprocal distance to that folded position. Summing inverse distances\n * is what concentrates energy into thin bright filaments where many folds\n * happen to land together — the same reason real caustics are the envelope of\n * refracted rays. The accumulation is then gamma-crushed hard so the mid-tones\n * fall away and only the focus lines survive, and the crush exponent differs\n * slightly per channel so the filaments carry a faint prismatic edge. The\n * pointer drops a decaying radial phase wave into the surface, which bends the\n * web outward around the cursor.\n */\nexport function Caustics({\n className,\n color = \"#a9c4ff\",\n background = \"#06060a\",\n intensity = 1,\n scale = 1.35,\n speed = 1,\n}: CausticsProps) {\n const uniforms = useMemo(\n () => ({\n uColor: hexToRgb(color),\n uBg: hexToRgb(background),\n uIntensity: intensity,\n uScale: scale,\n }),\n [color, background, intensity, scale],\n );\n\n const { ref, supported } = useShader({\n speed,\n uniforms,\n fragment: FRAG,\n });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n\n float t = uTime * 0.42 + 23.0;\n // The fold works on a large offset coordinate — at small |p| the reciprocal\n // distance saturates on every pass and the field flattens to a single tone.\n vec2 p = uv * uScale * 6.2831853 - 250.0;\n\n // A stone in the water above: a radial phase wave that displaces the sample\n // point, so the whole web refracts outward from the cursor rather than just\n // getting brighter under it.\n vec2 rel = uv - pc;\n float r = length(rel);\n float ripple = uPointer.z * sin(r * 24.0 - uTime * 4.6) * exp(-r * 4.5) * 0.85;\n p += normalize(rel + 1e-5) * ripple;\n\n // Fold the coordinate into itself; accumulate inverse distance each pass.\n vec2 q = p;\n float c = 1.0;\n const float INTEN = 0.0045;\n for (int n = 0; n < 5; n++) {\n float tn = t * (1.0 - (3.2 / float(n + 1)));\n q = p + vec2(cos(tn - q.x) + sin(tn + q.y), sin(tn - q.y) + cos(tn + q.x));\n float sx = sin(q.x + tn) / INTEN;\n float sy = cos(q.y + tn) / INTEN;\n c += 1.0 / length(vec2(p.x / (sx + 1e-4), p.y / (sy + 1e-4)));\n }\n c = 1.16 - pow(c / 5.0, 1.42);\n // Clamp before the gamma crush: c is an unbounded reciprocal-distance sum,\n // and pow(a, 7.9) on anything above 1.0 runs away to pure white.\n float a = clamp(abs(c), 0.0, 1.0);\n\n // Slightly different crush per channel: blue filaments end up marginally\n // wider than red, which reads as dispersion along every focus line.\n vec3 web = vec3(pow(a, 9.4), pow(a, 8.6), pow(a, 7.6)) * uIntensity;\n\n vec3 col = uBg + uColor * web * 1.45;\n col += uColor * 0.07 * pow(a, 3.4) * uIntensity; // the broad underwater wash\n col += uColor * uPointer.z * 0.15 * exp(-r * 5.5); // glow where the stone fell\n\n col = 1.0 - exp(-col * 1.9); // tone map — filaments stay bright, nothing clips\n col *= 1.0 - 0.34 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "water", + "caustics", + "background" + ] + } +} diff --git a/public/r/circular-gallery.json b/public/r/circular-gallery.json new file mode 100644 index 0000000..1fdb7ce --- /dev/null +++ b/public/r/circular-gallery.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "circular-gallery", + "type": "registry:component", + "title": "Circular Gallery", + "description": "Items arranged around a horizontal cylinder that you drag to spin, with inertia on release and optional snapping to the nearest item. Drag is converted through arc length so tracking is one-to-one at any radius, and items past the horizon dim and stop taking pointer events.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/circular-gallery.tsx", + "type": "registry:component", + "target": "components/reactomega/circular-gallery.tsx", + "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface CircularGalleryItem {\n /** Image URL. */\n src: string;\n /** Alternative text. Use \"\" for purely decorative imagery. */\n alt: string;\n /** Caption shown under the image and announced as the item's name. */\n label: string;\n}\n\nexport interface CircularGalleryProps extends Omit, \"onSelect\" | \"children\"> {\n /** Extra classes for the gallery shell. @default undefined */\n className?: string;\n /** Items placed evenly around the cylinder. @default [] */\n items?: CircularGalleryItem[];\n /** Cylinder radius in px — larger is a flatter arc. @default 420 */\n radius?: number;\n /** Width of one item in px. @default 220 */\n itemWidth?: number;\n /** Settle to the nearest item once the spin decays. @default true */\n snap?: boolean;\n /** Per-frame velocity multiplier after release, 0–1. @default 0.94 */\n friction?: number;\n /** Idle spin in degrees per second, 0 disables. @default 0 */\n autoRotate?: number;\n /** Fires when an item is chosen. @default undefined */\n onSelect?: (index: number, item: CircularGalleryItem) => void;\n}\n\n/** px of pointer travel before a press becomes a drag rather than a tap. */\nconst DRAG_SLOP = 4;\n\nconst mod = (a: number, n: number) => ((a % n) + n) % n;\n\n/**\n * CircularGallery — items pinned to the surface of a horizontal cylinder in CSS\n * 3D. Each item is fixed at `rotateY(i · 360/n) translateZ(radius)`, and only\n * the stage they share is animated: `translateZ(-radius) rotateY(rot)` pushes\n * the cylinder back by its own radius so the front item lands on the screen\n * plane at true size instead of looming at the camera. Dragging converts\n * pointer travel to rotation through the arc length of the cylinder\n * (`360 / 2πr` degrees per pixel), so the surface tracks the finger 1:1 whatever\n * the radius. Release hands the last frame's delta to an inertial integrator\n * that decays by `friction` each frame and then, if `snap` is on, eases to the\n * nearest multiple of the angle step — chosen as the equivalent angle closest\n * to the current rotation, so it never unwinds the long way round. Depth is\n * read back from each item's world angle: `cos` of the angle to the viewer\n * drives opacity and scale, and items past the horizon lose pointer events so\n * you cannot click the back of the cylinder through the front. Rotation is\n * written straight to `style.transform` every frame; only the active index is\n * React state.\n *\n * Accessibility: every item is a real `\n ))}\n \n \n\n
\n \n \n
\n\n
\n {n ? `${items[active]?.label ?? \"\"}, item ${active + 1} of ${n}` : \"Empty gallery\"}\n
\n \n );\n}\n" + } + ], + "meta": { + "category": "components", + "tags": [ + "components", + "gallery", + "carousel", + "3d", + "drag" + ] + } +} diff --git a/public/r/crosshair.json b/public/r/crosshair.json new file mode 100644 index 0000000..02d9934 --- /dev/null +++ b/public/r/crosshair.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "crosshair", + "type": "registry:component", + "title": "Crosshair", + "description": "A precision overlay: full-bleed hairlines, a centre reticle and a live monospace coordinate readout that snap to nearby targets. Distance is measured point-to-rectangle so hovering a target always locks, and one smoothstepped scalar drives the whole engage.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/crosshair.tsx", + "type": "registry:component", + "target": "components/reactomega/crosshair.tsx", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface CrosshairProps {\n /** Content the crosshair is laid over. */\n children?: React.ReactNode;\n className?: string;\n /** Hairline and reticle color. @default \"#7c5cff\" */\n color?: string;\n /** Hairline weight in px. @default 1 */\n thickness?: number;\n /** Elements the crosshair locks onto. @default \"[data-snap]\" */\n snapSelector?: string;\n /** Px from a target's edge at which the lock engages. @default 80 */\n snapDistance?: number;\n /** Show the live coordinate readout. @default true */\n showCoords?: boolean;\n}\n\n/**\n * Crosshair — a precision-instrument overlay: full-bleed hairlines tracking the\n * pointer, a corner-bracket reticle, and a monospace readout of the pointer's\n * position in container space.\n *\n * Its one real trick is snapping. Each frame the nearest element matching\n * `snapSelector` is measured with the clamped point-to-rectangle distance — zero\n * while the pointer is inside the target, so a hover always locks — and if that\n * is within `snapDistance` the crosshair acquires it. Acquisition drives a single\n * eased 0..1 value that the hairline position interpolates along, from the raw\n * pointer to the target's centre, while the reticle simultaneously grows from an\n * 18px box into a bracket around the target's bounding box. One scalar, both\n * behaviours, so the lock can never half-engage.\n *\n * Every rect — the container's and each target's — is read live each frame, so\n * the instrument stays calibrated through scrolls, resizes and layout shifts, and\n * targets added after mount are picked up automatically. Box dimensions are only\n * written when they actually change, keeping the steady state to pure transforms.\n * The loop idles once the lock has settled. The overlay is inert and aria-hidden;\n * the children below it stay fully clickable and keyboard reachable. Reduced\n * motion keeps the snapping but removes the glide, and coarse pointers get the\n * children with no overlay at all.\n */\nexport function Crosshair({\n children,\n className,\n color = \"#7c5cff\",\n thickness = 1,\n snapSelector = \"[data-snap]\",\n snapDistance = 80,\n showCoords = true,\n}: CrosshairProps) {\n const wrapRef = useRef(null);\n const layerRef = useRef(null);\n const hRef = useRef(null);\n const vRef = useRef(null);\n const boxRef = useRef(null);\n const labelRef = useRef(null);\n const [fine, setFine] = useState(false);\n const reduced = usePrefersReducedMotion();\n\n useEffect(() => {\n if (typeof window === \"undefined\" || !window.matchMedia) return;\n const mq = window.matchMedia(\"(pointer: fine)\");\n const update = () => setFine(mq.matches);\n update();\n mq.addEventListener?.(\"change\", update);\n return () => mq.removeEventListener?.(\"change\", update);\n }, []);\n\n useEffect(() => {\n if (!fine) return;\n const wrap = wrapRef.current;\n const layer = layerRef.current;\n const hLine = hRef.current;\n const vLine = vRef.current;\n const box = boxRef.current;\n if (!wrap || !layer || !hLine || !vLine || !box) return;\n\n let safeSelector = snapSelector;\n try {\n wrap.querySelector(snapSelector);\n } catch {\n safeSelector = \"\";\n }\n\n const BASE = 18; // reticle size with nothing acquired\n let px = 0;\n let py = 0;\n let inside = false;\n let lock = 0; // eased 0..1 acquisition\n let lastW = -1;\n let lastH = -1;\n let lastText = \"\";\n let raf = 0;\n let running = false;\n\n const step = () => {\n const r = wrap.getBoundingClientRect();\n\n // Nearest target by clamped point-to-rect distance (0 when pointer is inside it).\n let tx = px;\n let ty = py;\n let tw = BASE;\n let th = BASE;\n let found = false;\n if (inside && safeSelector) {\n let best = Infinity;\n const targets = wrap.querySelectorAll(safeSelector);\n for (let i = 0; i < targets.length; i++) {\n const b = targets[i].getBoundingClientRect();\n const left = b.left - r.left;\n const top = b.top - r.top;\n const dx = Math.max(left - px, 0, px - (left + b.width));\n const dy = Math.max(top - py, 0, py - (top + b.height));\n const d = Math.hypot(dx, dy);\n if (d <= snapDistance && d < best) {\n best = d;\n tx = left + b.width / 2;\n ty = top + b.height / 2;\n tw = b.width + 10;\n th = b.height + 10;\n found = true;\n }\n }\n }\n\n const target = found ? 1 : 0;\n if (reduced) lock = target;\n else lock += (target - lock) * 0.3;\n const e = lock * lock * (3 - 2 * lock); // smoothstep\n\n const cx = px + (tx - px) * e;\n const cy = py + (ty - py) * e;\n const bw = BASE + (tw - BASE) * e;\n const bh = BASE + (th - BASE) * e;\n\n hLine.style.transform = `translate3d(0, ${(cy - thickness / 2).toFixed(2)}px, 0)`;\n vLine.style.transform = `translate3d(${(cx - thickness / 2).toFixed(2)}px, 0, 0)`;\n box.style.transform = `translate3d(${(cx - bw / 2).toFixed(2)}px, ${(cy - bh / 2).toFixed(2)}px, 0)`;\n // Layout writes only when the bracket actually resizes.\n if (Math.abs(bw - lastW) > 0.5) {\n lastW = bw;\n box.style.width = `${bw.toFixed(1)}px`;\n }\n if (Math.abs(bh - lastH) > 0.5) {\n lastH = bh;\n box.style.height = `${bh.toFixed(1)}px`;\n }\n\n const label = labelRef.current;\n if (label) {\n const text = `${Math.round(cx)}, ${Math.round(cy)}`;\n if (text !== lastText) {\n lastText = text;\n label.textContent = text;\n }\n // Keep the readout inside the box by flipping it near the far edges.\n const flipX = cx > r.width - 96;\n const flipY = cy > r.height - 40;\n label.style.transform =\n `translate3d(${(cx + (flipX ? -14 : 14)).toFixed(1)}px, ${(cy + (flipY ? -14 : 14)).toFixed(1)}px, 0)` +\n ` translate(${flipX ? \"-100%\" : \"0\"}, ${flipY ? \"-100%\" : \"0\"})`;\n }\n\n if (!reduced && Math.abs(target - lock) > 0.002) raf = requestAnimationFrame(step);\n else running = false;\n };\n\n const ensure = () => {\n if (!running) {\n running = true;\n raf = requestAnimationFrame(step);\n }\n };\n\n const onMove = (e: PointerEvent) => {\n const r = wrap.getBoundingClientRect();\n px = e.clientX - r.left;\n py = e.clientY - r.top;\n inside = true;\n ensure();\n };\n const onEnter = () => {\n inside = true;\n layer.style.opacity = \"1\";\n ensure();\n };\n const onLeave = () => {\n inside = false;\n layer.style.opacity = \"0\";\n ensure();\n };\n\n wrap.addEventListener(\"pointerenter\", onEnter);\n wrap.addEventListener(\"pointermove\", onMove, { passive: true });\n wrap.addEventListener(\"pointerleave\", onLeave);\n ensure();\n\n return () => {\n cancelAnimationFrame(raf);\n wrap.removeEventListener(\"pointerenter\", onEnter);\n wrap.removeEventListener(\"pointermove\", onMove);\n wrap.removeEventListener(\"pointerleave\", onLeave);\n };\n }, [fine, reduced, color, thickness, snapSelector, snapDistance, showCoords]);\n\n // borderStyle alone would default the unset sides to `medium` and draw a full\n // box, so every side starts at zero width and the caller adds back just two.\n const corner = (style: React.CSSProperties) => (\n \n );\n\n return (\n
\n {children}\n {fine && (\n \n \n \n
\n {corner({ left: -1, top: -1, borderLeftWidth: thickness + 1, borderTopWidth: thickness + 1 })}\n {corner({ right: -1, top: -1, borderRightWidth: thickness + 1, borderTopWidth: thickness + 1 })}\n {corner({ left: -1, bottom: -1, borderLeftWidth: thickness + 1, borderBottomWidth: thickness + 1 })}\n {corner({ right: -1, bottom: -1, borderRightWidth: thickness + 1, borderBottomWidth: thickness + 1 })}\n
\n {showCoords && (\n \n )}\n
\n )}\n \n );\n}\n" + } + ], + "meta": { + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "crosshair", + "snapping", + "instrument" + ] + } +} diff --git a/public/r/curl-flow.json b/public/r/curl-flow.json new file mode 100644 index 0000000..af332a1 --- /dev/null +++ b/public/r/curl-flow.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "curl-flow", + "type": "registry:component", + "title": "Curl Flow", + "description": "Silk-like flowing bands from a curl-noise field. FBM acts as a stream function and the velocity is the perpendicular of its gradient, so the field is divergence-free by construction; coordinates advect along it and shade as anisotropic streamlines. The pointer injects a vortex.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/curl-flow.tsx", + "type": "registry:component", + "target": "components/reactomega/curl-flow.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface CurlFlowProps {\n className?: string;\n /** Colour of the silk. @default \"#9b8cff\" */\n tint?: string;\n /** Colour in the folds. @default \"#06060a\" */\n background?: string;\n /** Zoom of the flow field — higher gives narrower bands. @default 2.2 */\n scale?: number;\n /** Advection steps, 1..8. More steps means longer, silkier streamlines. @default 5 */\n steps?: number;\n /** Flow speed multiplier. @default 1 */\n speed?: number;\n /** Band hardness — higher pinches the highlights. @default 1.15 */\n contrast?: number;\n}\n\n/**\n * CurlFlow — wide bands of silk folding through each other, driven by a real\n * curl-noise field.\n *\n * A three-octave FBM is treated as a scalar stream function ψ. The velocity is\n * the perpendicular of its gradient, (∂ψ/∂y, −∂ψ/∂x), which is divergence-free\n * by construction — that is the whole trick, and it is why the flow swirls and\n * shears without ever piling up into a source or draining into a sink the way\n * a raw noise-vector field does. Each pixel walks its coordinate a few steps\n * along that velocity, so it ends up sampling the value it had upstream; ψ read\n * at the advected position is therefore stretched along the streamlines, and a\n * sine of it becomes bands that follow the flow. The highlight is anisotropic:\n * brightness depends on the alignment of the local tangent with the light, the\n * way sheen on real silk depends on fibre direction rather than surface normal.\n * The pointer adds a genuine vortex — a tangential term about the cursor —\n * into the velocity before each step, so the fabric twists rather than dents.\n */\nexport function CurlFlow({\n className,\n tint = \"#9b8cff\",\n background = \"#06060a\",\n scale = 2.2,\n steps = 5,\n speed = 1,\n contrast = 1.15,\n}: CurlFlowProps) {\n const uniforms = useMemo(\n () => ({\n uTint: hexToRgb(tint),\n uBg: hexToRgb(background),\n uScale: scale,\n uSteps: steps,\n uContrast: contrast,\n }),\n [tint, background, scale, steps, contrast],\n );\n\n const { ref, supported } = useShader({\n speed,\n uniforms,\n fragment: FRAG,\n });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat vnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\nfloat fbm3(vec2 p) {\n float a = 0.5;\n float v = 0.0;\n for (int i = 0; i < 3; i++) {\n v += a * vnoise(p);\n p = mat2(1.6, 1.2, -1.2, 1.6) * p;\n a *= 0.5;\n }\n return v;\n}\n\n// Scalar stream function.\nfloat potential(vec2 p, float t) {\n return fbm3(p + vec2(t * 0.09, t * 0.16));\n}\n\n// Curl of that potential: perpendicular of the gradient, so divergence is zero\n// and the field can only rotate and shear. Forward differences — three taps\n// instead of the four a central difference would cost, and the bias is invisible.\nvec2 curl(vec2 p, float t) {\n const float e = 0.045;\n float a = potential(p, t);\n float gx = (potential(p + vec2(e, 0.0), t) - a) / e;\n float gy = (potential(p + vec2(0.0, e), t) - a) / e;\n return vec2(gy, -gx);\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n float t = uTime * 0.3;\n\n vec2 q = uv * uScale;\n vec2 pcs = pc * uScale;\n float steps = clamp(uSteps, 1.0, 8.0);\n float h = 0.9 / steps; // total advected distance stays constant\n vec2 vel = vec2(0.0);\n\n for (int i = 0; i < 8; i++) {\n if (float(i) >= steps) break;\n vel = curl(q, t);\n vec2 r = q - pcs;\n float w = uPointer.z * exp(-dot(r, r) * 1.7);\n vel += vec2(-r.y, r.x) * w * 3.4; // vortex injected at the cursor\n q += vel * h * 0.60;\n }\n\n float s = potential(q, t);\n\n // Anisotropic sheen: silk is bright where the fibre tangent lines up with the\n // light, so the highlight rides the flow direction, not a surface normal.\n vec2 tangent = normalize(vel + vec2(1e-5, 1e-5));\n float aniso = pow(abs(dot(tangent, vec2(0.55, 0.84))), 3.5);\n\n float band = 0.5 + 0.5 * sin(s * 15.0 + t * 1.7);\n band = pow(band, max(uContrast, 0.05) * 3.0);\n\n float shade = clamp(band * (0.42 + 0.85 * aniso) + 0.05 * length(vel), 0.0, 1.0);\n\n vec3 hi = clamp(uTint * 1.45 + 0.16, 0.0, 1.0);\n vec3 col = mix(uBg, uTint, shade);\n col += hi * pow(shade, 4.5) * 0.55;\n\n col *= 1.0 - 0.38 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "curl-noise", + "flow", + "silk", + "background" + ] + } +} diff --git a/public/r/elastic-cursor.json b/public/r/elastic-cursor.json new file mode 100644 index 0000000..a2003d3 --- /dev/null +++ b/public/r/elastic-cursor.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "elastic-cursor", + "type": "registry:component", + "title": "Elastic Cursor", + "description": "A gooey cursor that stretches along its own velocity vector and squashes perpendicular so its area is conserved exactly, rotating into the direction of travel and settling back to a circle at rest. Optional trailing blobs are welded together by an SVG goo filter.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/elastic-cursor.tsx", + "type": "registry:component", + "target": "components/reactomega/elastic-cursor.tsx", + "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface ElasticCursorProps {\n className?: string;\n /** Diameter of the lead blob in px. @default 34 */\n size?: number;\n /** Blob color. @default \"#7c5cff\" */\n color?: string;\n /** How far the blob stretches at full speed, as a fraction of its size. @default 0.55 */\n stretch?: number;\n /** Spring acceleration, 0..1 — higher chases the pointer harder. @default 0.3 */\n stiffness?: number;\n /** Spring damping, 0..1 — lower settles with less wobble. @default 0.5 */\n damping?: number;\n /** Number of smaller blobs chained behind the lead one. @default 2 */\n trail?: number;\n /** Merge the blobs through an SVG goo filter. @default true */\n goo?: boolean;\n}\n\n/**\n * ElasticCursor — a gooey blob that deforms along its own velocity vector. Each\n * frame the blob's spring velocity gives both a direction and a speed: the blob\n * rotates to `atan2(vy, vx)` and scales by `1 + k` along that axis while scaling\n * by `1 / (1 + k)` across it, so the product stays 1 and the blob conserves its\n * area — it stretches into a teardrop under a flick and relaxes back to a perfect\n * circle at rest, never inflating.\n *\n * Speed maps to stretch through `1 - exp(-v / 14)`, a saturating curve, so there\n * is no clamp discontinuity when the pointer is thrown across the screen. Trailing\n * blobs are a follower chain — each one springs toward the one ahead with reduced\n * stiffness — and an SVG goo filter (blur plus an alpha contrast ramp) welds them\n * into a single surface that necks and snaps. The filter id comes from `useId()`\n * so several instances never collide.\n *\n * The loop idles once every blob has caught up. Coarse pointers render nothing;\n * reduced motion pins every blob to the pointer with no stretch and no rotation.\n */\nexport function ElasticCursor({\n className,\n size = 34,\n color = \"#7c5cff\",\n stretch = 0.55,\n stiffness = 0.3,\n damping = 0.5,\n trail = 2,\n goo = true,\n}: ElasticCursorProps) {\n const rootRef = useRef(null);\n const blobRefs = useRef<(HTMLDivElement | null)[]>([]);\n const [fine, setFine] = useState(false);\n const reduced = usePrefersReducedMotion();\n const filterId = \"ro-elastic-\" + useId().replace(/[^a-zA-Z0-9-]/g, \"\");\n\n const count = Math.max(1, Math.round(trail) + 1);\n const diameter = (i: number) => Math.round(size * Math.max(0.35, 1 - 0.22 * i));\n\n useEffect(() => {\n if (typeof window === \"undefined\" || !window.matchMedia) return;\n const mq = window.matchMedia(\"(pointer: fine)\");\n const update = () => setFine(mq.matches);\n update();\n mq.addEventListener?.(\"change\", update);\n return () => mq.removeEventListener?.(\"change\", update);\n }, []);\n\n useEffect(() => {\n if (!fine) return;\n const root = rootRef.current;\n if (!root) return;\n const blobs = blobRefs.current.slice(0, count).filter(Boolean) as HTMLDivElement[];\n if (!blobs.length) return;\n\n const state = blobs.map(() => ({ x: -9999, y: -9999, vx: 0, vy: 0, a: 0 }));\n let px = -9999;\n let py = -9999;\n let seen = false;\n let raf = 0;\n let running = false;\n\n const step = () => {\n const r = root.getBoundingClientRect();\n let active = false;\n\n for (let i = 0; i < blobs.length; i++) {\n const s = state[i];\n // Blob 0 chases the pointer; every other blob chases the one in front.\n const tx = i === 0 ? px : state[i - 1].x;\n const ty = i === 0 ? py : state[i - 1].y;\n\n if (reduced) {\n s.x = tx;\n s.y = ty;\n s.vx = 0;\n s.vy = 0;\n } else {\n const k = stiffness * Math.pow(0.72, i);\n s.vx = (s.vx + (tx - s.x) * k) * damping;\n s.vy = (s.vy + (ty - s.y) * k) * damping;\n s.x += s.vx;\n s.y += s.vy;\n }\n\n const speed = Math.hypot(s.vx, s.vy);\n // Saturating response: fast flicks approach full stretch without a clamp.\n const t = reduced ? 0 : 1 - Math.exp(-speed / 14);\n const sx = 1 + stretch * t;\n const sy = 1 / sx; // area preserved: sx * sy === 1\n if (speed > 0.6) s.a = (Math.atan2(s.vy, s.vx) * 180) / Math.PI;\n\n blobs[i].style.transform =\n `translate3d(${(s.x - r.left).toFixed(2)}px, ${(s.y - r.top).toFixed(2)}px, 0)` +\n ` translate(-50%, -50%) rotate(${s.a.toFixed(2)}deg) scale(${sx.toFixed(3)}, ${sy.toFixed(3)})`;\n\n if (Math.abs(tx - s.x) > 0.05 || Math.abs(ty - s.y) > 0.05 || speed > 0.05) active = true;\n }\n\n if (active) raf = requestAnimationFrame(step);\n else running = false;\n };\n\n const ensure = () => {\n if (!running) {\n running = true;\n raf = requestAnimationFrame(step);\n }\n };\n\n const onMove = (e: PointerEvent) => {\n px = e.clientX;\n py = e.clientY;\n if (!seen) {\n seen = true;\n for (const s of state) {\n s.x = px;\n s.y = py;\n }\n root.style.opacity = \"1\";\n }\n ensure();\n };\n const onOut = (e: PointerEvent) => {\n if (e.relatedTarget) return;\n root.style.opacity = \"0\";\n seen = false;\n };\n const onBlur = () => {\n root.style.opacity = \"0\";\n seen = false;\n };\n\n document.addEventListener(\"pointermove\", onMove, { passive: true });\n document.addEventListener(\"pointerout\", onOut, { passive: true });\n window.addEventListener(\"blur\", onBlur);\n ensure();\n\n return () => {\n cancelAnimationFrame(raf);\n document.removeEventListener(\"pointermove\", onMove);\n document.removeEventListener(\"pointerout\", onOut);\n window.removeEventListener(\"blur\", onBlur);\n };\n }, [fine, reduced, count, size, stretch, stiffness, damping]);\n\n if (!fine) return null;\n\n return (\n \n {goo && !reduced && (\n \n \n \n \n \n \n \n \n )}\n {Array.from({ length: count }).map((_, i) => (\n {\n blobRefs.current[i] = el;\n }}\n className=\"absolute left-0 top-0 rounded-full will-change-transform\"\n style={{ width: diameter(i), height: diameter(i), background: color }}\n />\n ))}\n \n );\n}\n" + } + ], + "meta": { + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "goo", + "elastic", + "svg" + ] + } +} diff --git a/public/r/elastic-slider.json b/public/r/elastic-slider.json new file mode 100644 index 0000000..8032d14 --- /dev/null +++ b/public/r/elastic-slider.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "elastic-slider", + "type": "registry:component", + "title": "Elastic Slider", + "description": "A slider whose rail stretches like rubber past either end with exponentially diminishing overshoot, then springs back on release. Implements the full slider ARIA contract with arrow, page, home and end keys, pointer capture and touch support.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/elastic-slider.tsx", + "type": "registry:component", + "target": "components/reactomega/elastic-slider.tsx", + "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface ElasticSliderProps\n extends Omit, \"onChange\" | \"defaultValue\" | \"children\"> {\n /** Extra classes for the row that holds the icons and the track. @default undefined */\n className?: string;\n /** Controlled value. @default undefined */\n value?: number;\n /** Uncontrolled starting value. @default 50 */\n defaultValue?: number;\n /** Lower bound. @default 0 */\n min?: number;\n /** Upper bound. @default 100 */\n max?: number;\n /** Quantisation step. @default 1 */\n step?: number;\n /** Fires with every committed value. @default undefined */\n onChange?: (value: number) => void;\n /** Maximum rubber-band overshoot in px at either end, 0 disables. @default 56 */\n elasticity?: number;\n /** Formats the value for the readout and `aria-valuetext`. @default (v) => String(v) */\n formatValue?: (value: number) => string;\n /** Node rendered before the track. @default undefined */\n leftIcon?: React.ReactNode;\n /** Node rendered after the track. @default undefined */\n rightIcon?: React.ReactNode;\n}\n\n/** Spring constants for the release snap-back. */\nconst SPRING_K = 0.24;\nconst SPRING_DAMP = 0.7;\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/**\n * ElasticSlider — a slider whose track rubber-bands when you drag past either\n * end. Pointer travel beyond the rail is converted to overshoot through\n * `limit · (1 - e^(-excess / limit))`, an asymptotic curve: the first pixels\n * past the end move almost freely, the last ones barely move at all, and the\n * result can never exceed `elasticity` no matter how far you pull — the\n * diminishing-returns feel of a real elastic band, which a linear clamp cannot\n * produce. The overshoot becomes a `scaleX` on the whole track anchored to the\n * opposite end (so the rail genuinely stretches rather than translating), with\n * a reciprocal `scaleX` on the thumb to keep it circular. Releasing hands the\n * overshoot to a damped spring integrated in `requestAnimationFrame`; the rail\n * also swells on the Y axis while the pointer is down. All of that is written\n * straight to `style.transform` each frame, while the value itself stays\n * ordinary React state, and the rail width is re-read live so the maths\n * survives a resizing or scrolling page.\n *\n * Accessibility: the track carries the full `role=\"slider\"` contract —\n * `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext` /\n * `aria-orientation` — and is focusable, with Arrow keys stepping,\n * PageUp/PageDown taking a larger jump, and Home/End going to the bounds.\n * Dragging uses Pointer Events with pointer capture and `touch-action: none`,\n * so it works identically with touch, pen and mouse. Reduced motion keeps every\n * one of those interactions and simply removes the stretch, the swell and the\n * spring.\n */\nexport function ElasticSlider({\n className,\n value,\n defaultValue = 50,\n min = 0,\n max = 100,\n step = 1,\n onChange,\n elasticity = 56,\n formatValue,\n leftIcon,\n rightIcon,\n \"aria-label\": ariaLabel = \"Value\",\n \"aria-labelledby\": ariaLabelledBy,\n style,\n ...rest\n}: ElasticSliderProps) {\n const reduced = usePrefersReducedMotion();\n const trackRef = useRef(null);\n const stretchRef = useRef(null);\n const railRef = useRef(null);\n const thumbRef = useRef(null);\n\n const over = useRef(0);\n const vel = useRef(0);\n const swell = useRef(0);\n const dragging = useRef(false);\n const raf = useRef(0);\n\n const [internal, setInternal] = useState(defaultValue);\n const current = value ?? internal;\n\n const span = max - min || 1;\n const decimals = (() => {\n const s = String(step);\n const dot = s.indexOf(\".\");\n return dot < 0 ? 0 : s.length - dot - 1;\n })();\n const quantize = useCallback(\n (v: number) => {\n const snapped = Math.round((v - min) / step) * step + min;\n return clamp(Number(snapped.toFixed(decimals)), min, max);\n },\n [min, max, step, decimals],\n );\n\n const safe = clamp(current, min, max);\n const pct = ((safe - min) / span) * 100;\n const readout = formatValue ? formatValue(safe) : String(safe);\n\n const commit = useCallback(\n (raw: number) => {\n const next = quantize(raw);\n if (value === undefined) setInternal(next);\n if (next !== safe) onChange?.(next);\n },\n [quantize, value, safe, onChange],\n );\n\n const paint = useCallback(() => {\n const track = trackRef.current;\n const stretch = stretchRef.current;\n const rail = railRef.current;\n const thumb = thumbRef.current;\n if (!track || !stretch || !rail || !thumb) return;\n // Live width every frame — a cached rect goes stale as the page reflows.\n const w = track.getBoundingClientRect().width || 1;\n const o = over.current;\n const s = 1 + Math.abs(o) / w;\n stretch.style.transformOrigin = o > 0 ? \"left center\" : \"right center\";\n stretch.style.transform = `scaleX(${s.toFixed(4)})`;\n rail.style.transform = `scaleY(${(1 + swell.current * 0.7).toFixed(3)})`;\n thumb.style.transform = `translate(-50%,-50%) scaleX(${(1 / s).toFixed(4)}) scale(${(1 + swell.current * 0.14).toFixed(3)})`;\n }, []);\n\n const tick = useCallback(() => {\n raf.current = 0;\n if (!dragging.current) {\n vel.current = (vel.current - over.current * SPRING_K) * SPRING_DAMP;\n over.current += vel.current;\n if (Math.abs(over.current) < 0.08 && Math.abs(vel.current) < 0.08) {\n over.current = 0;\n vel.current = 0;\n }\n }\n swell.current += ((dragging.current ? 1 : 0) - swell.current) * 0.22;\n if (swell.current < 0.004 && !dragging.current) swell.current = 0;\n paint();\n if (dragging.current || over.current !== 0 || swell.current !== 0) {\n raf.current = requestAnimationFrame(tick);\n }\n }, [paint]);\n\n const ensureLoop = useCallback(() => {\n if (reduced) return;\n if (!raf.current) raf.current = requestAnimationFrame(tick);\n }, [reduced, tick]);\n\n useEffect(() => () => cancelAnimationFrame(raf.current), []);\n\n // Keep the rendered thumb geometry correct after value / size changes.\n useEffect(() => {\n if (reduced) return;\n if (!raf.current) paint();\n }, [safe, reduced, paint]);\n\n const fromClientX = (clientX: number) => {\n const track = trackRef.current;\n if (!track) return;\n const r = track.getBoundingClientRect();\n const w = r.width || 1;\n const ratio = (clientX - r.left) / w;\n commit(min + clamp(ratio, 0, 1) * span);\n\n if (reduced || elasticity <= 0) {\n over.current = 0;\n return;\n }\n const excess = ratio < 0 ? -ratio * w : ratio > 1 ? (ratio - 1) * w : 0;\n const band = elasticity * (1 - Math.exp(-excess / elasticity));\n over.current = ratio < 0 ? -band : ratio > 1 ? band : 0;\n };\n\n const onPointerDown = (e: React.PointerEvent) => {\n if (e.button !== undefined && e.button !== 0) return;\n e.preventDefault();\n e.currentTarget.setPointerCapture?.(e.pointerId);\n e.currentTarget.focus();\n dragging.current = true;\n vel.current = 0; // drop any momentum left over from a previous snap-back\n fromClientX(e.clientX);\n ensureLoop();\n };\n\n const onPointerMove = (e: React.PointerEvent) => {\n if (!dragging.current) return;\n fromClientX(e.clientX);\n ensureLoop();\n };\n\n const endDrag = (e: React.PointerEvent) => {\n if (!dragging.current) return;\n dragging.current = false;\n e.currentTarget.releasePointerCapture?.(e.pointerId);\n ensureLoop();\n };\n\n const onKeyDown = (e: React.KeyboardEvent) => {\n const big = Math.max(step * 10, span / 10);\n let next: number | null = null;\n switch (e.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = safe + step;\n break;\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = safe - step;\n break;\n case \"PageUp\":\n next = safe + big;\n break;\n case \"PageDown\":\n next = safe - big;\n break;\n case \"Home\":\n next = min;\n break;\n case \"End\":\n next = max;\n break;\n default:\n return;\n }\n e.preventDefault();\n commit(clamp(next, min, max));\n };\n\n return (\n
\n {leftIcon != null && (\n \n {leftIcon}\n \n )}\n\n \n
\n
\n \n
\n \n
\n
\n\n {rightIcon != null && (\n \n {rightIcon}\n \n )}\n \n {readout}\n \n \n );\n}\n" + } + ], + "meta": { + "category": "components", + "tags": [ + "components", + "slider", + "input", + "elastic", + "a11y" + ] + } +} diff --git a/public/r/gooey-nav.json b/public/r/gooey-nav.json new file mode 100644 index 0000000..ab982ac --- /dev/null +++ b/public/r/gooey-nav.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "gooey-nav", + "type": "registry:component", + "title": "Gooey Nav", + "description": "A pill navigation whose liquid indicator stretches as it travels between items and settles into a single pill, fused by an SVG goo filter. Real buttons with roving tabindex and arrow-key movement; labels stay outside the filter so text remains crisp.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/gooey-nav.tsx", + "type": "registry:component", + "target": "components/reactomega/gooey-nav.tsx", + "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface GooeyNavItem {\n /** Visible label. */\n label: string;\n /** Stable value — also used as the tab's DOM id so a panel can point at it with `aria-labelledby`. */\n id: string;\n}\n\nexport interface GooeyNavProps extends Omit, \"onChange\" | \"defaultValue\"> {\n /** Extra classes for the nav shell. @default undefined */\n className?: string;\n /** The items to render, left to right. @default [] */\n items?: GooeyNavItem[];\n /** Controlled selected id. @default undefined */\n value?: string;\n /** Uncontrolled starting id. @default first item's id */\n defaultValue?: string;\n /** Fires with the newly selected id. @default undefined */\n onChange?: (id: string) => void;\n /** Blob colour. @default \"#7c5cff\" */\n color?: string;\n /** Blur radius fed to the goo filter — higher is more liquid. @default 9 */\n gooStrength?: number;\n /** 0–1. How far the trailing blob lags, i.e. how much the blob elongates. @default 0.6 */\n stretch?: number;\n}\n\n/** ms the leading blob takes to reach the new item. */\nconst LEAD_MS = 380;\n\n/**\n * GooeyNav — a pill navigation where a liquid indicator flows between items.\n * The indicator is two identically coloured blobs sharing one SVG goo filter\n * (`feGaussianBlur` → `feColorMatrix` alpha contrast, which re-hardens the\n * blurred edges so overlapping shapes fuse). The leading blob reaches the new\n * item in `LEAD_MS`; the trailing blob is given a longer duration and a small\n * delay scaled by `stretch`, so mid-flight the two are apart and the filter\n * renders them as one elongated capsule pointing back the way it came, which\n * then settles into a single pill when the trail catches up. A brief scaleY\n * squash on the leading blob adds the surface-tension snap. Geometry comes\n * from the target button's `offsetLeft` / `offsetWidth` / `offsetTop` /\n * `offsetHeight`, re-measured by a `ResizeObserver`, so the blob tracks\n * reflows and font swaps without hard-coded sizes.\n *\n * Accessibility: labels live *outside* the filtered layer (a goo filter would\n * smear text), so they stay crisp and selectable. The items are real\n * `\n ))}\n \n );\n}\n" + } + ], + "meta": { + "category": "components", + "tags": [ + "components", + "nav", + "goo", + "svg", + "tabs" + ] + } +} diff --git a/public/r/halftone-gradient.json b/public/r/halftone-gradient.json new file mode 100644 index 0000000..3387c3c --- /dev/null +++ b/public/r/halftone-gradient.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "halftone-gradient", + "type": "registry:component", + "title": "Halftone Gradient", + "description": "A drifting multi-stop gradient rendered through a print screen — either angled per-channel dots with radius proportional to the square root of tone, or a computed 4x4 ordered Bayer matrix. The screen opens up under the pointer.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/halftone-gradient.tsx", + "type": "registry:component", + "target": "components/reactomega/halftone-gradient.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface HalftoneGradientProps {\n className?: string;\n /** First gradient stop. @default \"#7c5cff\" */\n from?: string;\n /** Last gradient stop — the midpoint is derived from both. @default \"#4fd1ff\" */\n to?: string;\n /** Screen pitch in device pixels: dot spacing, or Bayer block size. @default 14 */\n dotSize?: number;\n /** Screen angle in degrees. Also sets the gradient direction. @default 22 */\n angle?: number;\n /** Drift speed of the gradient underneath the screen. @default 1 */\n speed?: number;\n /** Rotated dot screen, or an ordered 4x4 Bayer matrix. @default \"dots\" */\n mode?: \"dots\" | \"bayer\";\n}\n\n/**\n * HalftoneGradient — a drifting multi-stop gradient printed through a screen,\n * so it resolves into discrete ink rather than a smooth blend.\n *\n * The continuous field underneath is a directional ramp perturbed by three\n * detuned sines, run through a three-stop ramp (from → a lifted midpoint → to).\n * That field is then destroyed on purpose. In \"dots\" mode each channel gets its\n * own rotated dot screen 30° apart, exactly as CMY separations are angled in\n * print — the offset is what produces rosettes instead of a moiré clash — and\n * dot radius goes as √value so dot *area* stays linear in tone, which is why\n * mid-greys do not print muddy. In \"bayer\" mode a 4×4 ordered dither matrix\n * (computed arithmetically, no lookup table) biases a per-channel quantiser to\n * four levels, giving flat risograph plates with a visible weave. Antialiasing\n * is derived from the cell pitch rather than fwidth so cell seams stay clean.\n * The pointer shrinks the local pitch, opening the screen up under the cursor.\n */\nexport function HalftoneGradient({\n className,\n from = \"#7c5cff\",\n to = \"#4fd1ff\",\n dotSize = 14,\n angle = 22,\n speed = 1,\n mode = \"dots\",\n}: HalftoneGradientProps) {\n const uniforms = useMemo(\n () => ({\n uFrom: hexToRgb(from),\n uTo: hexToRgb(to),\n uCell: dotSize,\n uAngle: (angle * Math.PI) / 180,\n uMode: mode === \"bayer\" ? 1 : 0,\n }),\n [from, to, dotSize, angle, mode],\n );\n\n const { ref, supported } = useShader({\n speed,\n uniforms,\n fragment: FRAG,\n });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nvec2 rot2(vec2 p, float a) {\n float c = cos(a);\n float s = sin(a);\n return vec2(c * p.x - s * p.y, s * p.x + c * p.y);\n}\n\n// 2x2 then 4x4 ordered dither, built from the recursive definition instead of a\n// table. Wrapped to 0..3 first so y*y never loses mantissa on tall buffers.\nfloat bayer2(vec2 a) {\n a = floor(a);\n return fract(a.x * 0.5 + a.y * a.y * 0.75);\n}\n\nfloat bayer4(vec2 a) {\n a = mod(a, 4.0);\n return bayer2(a * 0.5) * 0.25 + bayer2(a);\n}\n\n// One rotated dot screen. Radius tracks sqrt(value) so covered *area* is linear\n// in tone — the standard print correction.\nfloat screen(vec2 frag, float cell, float ang, float value) {\n vec2 q = rot2(frag, ang) / cell;\n float d = length(fract(q) - 0.5);\n float rad = sqrt(clamp(value, 0.0, 1.0)) * 0.52;\n float aa = 1.4 / cell;\n return smoothstep(rad + aa, rad - aa, d);\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n float t = uTime;\n\n // Continuous field: a directional ramp with three detuned sines on top, so\n // the stops never sit still and never repeat cleanly.\n vec2 dir = vec2(cos(uAngle), sin(uAngle));\n float g = dot(uv, dir) * 1.15 + 0.5;\n g += 0.20 * sin(uv.y * 3.3 - t * 0.61)\n + 0.15 * sin(uv.x * 2.4 + t * 0.47)\n + 0.09 * sin((uv.x + uv.y) * 5.1 - t * 0.83);\n g = clamp(g, 0.0, 1.0);\n\n vec3 mid = clamp(mix(uFrom, uTo, 0.5) * 1.32 + 0.05, 0.0, 1.0);\n vec3 grad = mix(mix(uFrom, mid, clamp(g * 2.0, 0.0, 1.0)), uTo, clamp(g * 2.0 - 1.0, 0.0, 1.0));\n\n // Pointer opens the screen: finer pitch, a touch more ink.\n vec2 rel = uv - pc;\n float near = uPointer.z * exp(-dot(rel, rel) * 7.0);\n float cell = max(uCell * mix(1.0, 0.38, near), 2.0);\n\n float v = (0.20 + 0.88 * g) * (1.0 - 0.40 * dot(uv, uv)) + 0.20 * near;\n vec3 c = clamp(grad * v * 0.98, 0.0, 1.0);\n\n vec3 ink;\n if (uMode > 0.5) {\n float th = bayer4(floor(gl_FragCoord.xy / cell)) - 0.5;\n ink = floor(c * 4.0 + th + 0.5) * 0.25 * 0.95;\n } else {\n vec3 cov = vec3(screen(gl_FragCoord.xy, cell, uAngle + 0.2618, c.r),\n screen(gl_FragCoord.xy, cell, uAngle + 0.7854, c.g),\n screen(gl_FragCoord.xy, cell, uAngle + 1.3090, c.b));\n // Separated screens are what makes a rosette, but taken literally every dot\n // lands on a pure primary. Blending back toward shared coverage keeps the\n // interference pattern while the colour stays in the gradient.\n float shared = dot(cov, vec3(0.3333));\n ink = mix(cov, vec3(shared), 0.62) * grad * 1.02;\n }\n\n vec3 paper = uFrom * 0.035 + vec3(0.016, 0.016, 0.026);\n vec3 col = paper + ink;\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.010;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "halftone", + "dither", + "print", + "background" + ] + } +} diff --git a/public/r/image-trail.json b/public/r/image-trail.json new file mode 100644 index 0000000..1f151ef --- /dev/null +++ b/public/r/image-trail.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "image-trail", + "type": "registry:component", + "title": "Image Trail", + "description": "Drops images along the pointer's path as it crosses a container, each fading and scaling out. Emission is thresholded by distance travelled rather than by event, with the remainder carried between events, so spacing stays even at any speed.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/image-trail.tsx", + "type": "registry:component", + "target": "components/reactomega/image-trail.tsx", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface ImageTrailProps {\n /** Image sources dropped along the pointer path, cycled in order. */\n images: string[];\n /** Content rendered underneath the trail. */\n children?: React.ReactNode;\n className?: string;\n /** Size of the recycled image pool — the most that can be on screen at once. @default 8 */\n count?: number;\n /** Px the pointer must travel before the next image is dropped. @default 60 */\n threshold?: number;\n /** Lifetime of each image in ms. @default 750 */\n duration?: number;\n /** Image width in px; height follows the intrinsic ratio. @default 160 */\n width?: number;\n /** Max tilt in degrees, taken from the direction of travel. @default 12 */\n rotation?: number;\n}\n\n/**\n * ImageTrail — images fall out of the pointer as it crosses the container, each\n * one popping in, tilting into the direction of travel, then fading and shrinking\n * away.\n *\n * The detail that makes this feel right is distance-thresholded emission: nothing\n * is dropped per pointermove event. Instead the distance the pointer has travelled\n * is banked, and images are dropped at exact multiples of `threshold` measured\n * along the path, with the remainder carried into the next event — so one fast\n * event can lay down several images in a row and spacing stays exactly `threshold`\n * whatever the pointer speed. Event-rate emission would clot on a slow drag and go\n * dotted on a flick; even a naive accumulator that resets to the event position\n * would round spacing up to a whole multiple of the per-event travel.\n *\n * Images come from a fixed pool of `count` DOM nodes written round-robin, so a long\n * drag never allocates — the oldest image is simply reclaimed. Every position is\n * measured against the container's live rect at drop time, so the trail lands\n * correctly on a scrolled page, and sources are pre-decoded on mount so the first\n * pass does not flicker. The loop idles when the last image dies. Reduced motion\n * and coarse pointers drop nothing at all; the children render untouched.\n */\nexport function ImageTrail({\n images,\n children,\n className,\n count = 8,\n threshold = 60,\n duration = 750,\n width = 160,\n rotation = 12,\n}: ImageTrailProps) {\n const wrapRef = useRef(null);\n const itemRefs = useRef<(HTMLImageElement | null)[]>([]);\n const [fine, setFine] = useState(false);\n const reduced = usePrefersReducedMotion();\n\n useEffect(() => {\n if (typeof window === \"undefined\" || !window.matchMedia) return;\n const mq = window.matchMedia(\"(pointer: fine)\");\n const update = () => setFine(mq.matches);\n update();\n mq.addEventListener?.(\"change\", update);\n return () => mq.removeEventListener?.(\"change\", update);\n }, []);\n\n // Warm the cache so the first lap of the pool does not pop in blank.\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n for (const src of images) {\n const img = new Image();\n img.src = src;\n }\n }, [images]);\n\n useEffect(() => {\n if (!fine || reduced || !images.length) return;\n const wrap = wrapRef.current;\n if (!wrap) return;\n const items = itemRefs.current.slice(0, count).filter(Boolean) as HTMLImageElement[];\n if (!items.length) return;\n\n const slots = items.map(() => ({ born: -1, x: 0, y: 0, deg: 0 }));\n const gap = Math.max(1, threshold); // a zero threshold would divide by zero\n let head = 0;\n let imageIndex = 0;\n let lastX = 0;\n let lastY = 0;\n let acc = 0; // distance banked since the last drop, remainder carried forward\n let primed = false;\n let raf = 0;\n let running = false;\n\n const step = () => {\n const now = performance.now();\n let alive = false;\n\n for (let i = 0; i < items.length; i++) {\n const s = slots[i];\n if (s.born < 0) continue;\n const p = (now - s.born) / duration;\n if (p >= 1) {\n s.born = -1;\n items[i].style.opacity = \"0\";\n continue;\n }\n alive = true;\n // Quick pop in, long hold, accelerating fade out.\n const rise = Math.min(1, p / 0.09);\n const scale = (0.82 + 0.18 * rise) * (1 - 0.18 * p);\n items[i].style.opacity = (rise * (1 - p * p)).toFixed(3);\n items[i].style.transform =\n `translate3d(${s.x.toFixed(1)}px, ${s.y.toFixed(1)}px, 0)` +\n ` translate(-50%, -50%) rotate(${s.deg.toFixed(2)}deg) scale(${scale.toFixed(3)})`;\n }\n\n if (alive) raf = requestAnimationFrame(step);\n else running = false;\n };\n\n const ensure = () => {\n if (!running) {\n running = true;\n raf = requestAnimationFrame(step);\n }\n };\n\n const emit = (x: number, y: number, dx: number) => {\n const i = head % items.length;\n head++;\n const s = slots[i];\n s.born = performance.now();\n s.x = x;\n s.y = y;\n // Lean into the swipe: dx over one threshold of travel maps to full tilt.\n s.deg = Math.max(-rotation, Math.min(rotation, (dx / gap) * rotation));\n items[i].src = images[imageIndex % images.length];\n imageIndex++;\n items[i].style.zIndex = String(head);\n ensure();\n };\n\n const onMove = (e: PointerEvent) => {\n // Live rect — the page may have scrolled since the last event.\n const r = wrap.getBoundingClientRect();\n const x = e.clientX - r.left;\n const y = e.clientY - r.top;\n if (!primed) {\n primed = true;\n lastX = x;\n lastY = y;\n acc = 0;\n return;\n }\n const dx = x - lastX;\n const dy = y - lastY;\n const seg = Math.hypot(dx, dy);\n if (seg > 0) {\n const banked = acc; // travelled before this segment began\n acc += seg;\n // Drop at exact multiples of `threshold` measured along the path, and\n // carry the remainder. Resetting the accumulator to the event position\n // instead would stretch spacing to a whole multiple of the per-event\n // travel, so a fast flick would space images further apart than a slow\n // drag — the speed dependence this whole approach exists to remove.\n const drops = Math.min(items.length, Math.floor(acc / gap));\n let along = gap - banked;\n for (let n = 0; n < drops; n++) {\n const t = Math.max(0, Math.min(1, along / seg));\n emit(lastX + dx * t, lastY + dy * t, dx);\n along += threshold;\n }\n acc %= gap;\n }\n lastX = x;\n lastY = y;\n };\n const onEnter = (e: PointerEvent) => {\n const r = wrap.getBoundingClientRect();\n lastX = e.clientX - r.left;\n lastY = e.clientY - r.top;\n acc = 0;\n primed = true;\n };\n const onLeave = () => {\n primed = false;\n };\n\n wrap.addEventListener(\"pointerenter\", onEnter);\n wrap.addEventListener(\"pointermove\", onMove, { passive: true });\n wrap.addEventListener(\"pointerleave\", onLeave);\n\n return () => {\n cancelAnimationFrame(raf);\n wrap.removeEventListener(\"pointerenter\", onEnter);\n wrap.removeEventListener(\"pointermove\", onMove);\n wrap.removeEventListener(\"pointerleave\", onLeave);\n for (const el of items) el.style.opacity = \"0\";\n };\n }, [fine, reduced, images, count, threshold, duration, rotation]);\n\n return (\n
\n {children}\n
\n {Array.from({ length: count }).map((_, i) => (\n /* eslint-disable-next-line @next/next/no-img-element */\n {\n itemRefs.current[i] = el;\n }}\n alt=\"\"\n aria-hidden\n draggable={false}\n decoding=\"async\"\n className=\"absolute left-0 top-0 select-none rounded-lg object-cover will-change-transform\"\n style={{ width, height: \"auto\", opacity: 0 }}\n />\n ))}\n
\n
\n );\n}\n" + } + ], + "meta": { + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "trail", + "images", + "hover" + ] + } +} diff --git a/public/r/inertia-cursor.json b/public/r/inertia-cursor.json new file mode 100644 index 0000000..42453f9 --- /dev/null +++ b/public/r/inertia-cursor.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "inertia-cursor", + "type": "registry:component", + "title": "Inertia Cursor", + "description": "Replaces the native cursor with an exact dot plus a ring that lags on a spring, growing over anything matching a selector. Hover is delegated from the document so late-mounted elements work, and difference blending keeps it visible on any background.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/inertia-cursor.tsx", + "type": "registry:component", + "target": "components/reactomega/inertia-cursor.tsx", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface InertiaCursorProps {\n className?: string;\n /** Diameter of the precise inner dot in px. @default 7 */\n size?: number;\n /** Diameter of the lagging outer ring in px. @default 34 */\n ringSize?: number;\n /** Ring spring acceleration, 0..1 — higher catches up faster. @default 0.18 */\n stiffness?: number;\n /** Ring spring damping, 0..1 — lower kills the bounce harder. @default 0.62 */\n damping?: number;\n /** Ring scale while hovering an interactive target. @default 1.7 */\n hoverScale?: number;\n /** Selector the ring reacts to, tested with closest() on whatever is hovered. @default \"a, button, [data-cursor]\" */\n selector?: string;\n /** Cursor color. Painted with mix-blend-mode difference, so white inverts whatever sits behind it. @default \"#ffffff\" */\n color?: string;\n /** Hide the native cursor while this one is on screen. @default true */\n hideNative?: boolean;\n}\n\n/**\n * InertiaCursor — a two-part cursor replacement: a small dot pinned exactly to\n * the pointer, and a larger ring that trails behind it on a spring, so fast\n * flicks stretch the pair apart and a stop lets the ring coast in.\n *\n * Hover state is delegated from `document` via pointerover, so the ring reacts\n * to elements that were added to the DOM long after mount, and a scroll listener\n * re-tests the node under the cursor for the case where the page moves but the\n * mouse does not. Both halves are painted with `mix-blend-mode: difference`,\n * which keeps them readable over any background without knowing anything about\n * it. Positions are resolved against the overlay's live rect each frame so the\n * cursor stays true even if an ancestor transform captures the fixed container.\n *\n * The rAF loop idles as soon as the ring and the hover spring have settled and\n * restarts on the next pointer event. Coarse pointers render nothing at all —\n * a lagging ring on a touchscreen is just a stuck artifact. Reduced motion drops\n * the spring and the scale: the ring simply follows.\n */\nexport function InertiaCursor({\n className,\n size = 7,\n ringSize = 34,\n stiffness = 0.18,\n damping = 0.62,\n hoverScale = 1.7,\n selector = \"a, button, [data-cursor]\",\n color = \"#ffffff\",\n hideNative = true,\n}: InertiaCursorProps) {\n const rootRef = useRef(null);\n const dotRef = useRef(null);\n const ringRef = useRef(null);\n const [fine, setFine] = useState(false);\n const reduced = usePrefersReducedMotion();\n\n // Only mouse-like pointers get a cursor replacement.\n useEffect(() => {\n if (typeof window === \"undefined\" || !window.matchMedia) return;\n const mq = window.matchMedia(\"(pointer: fine)\");\n const update = () => setFine(mq.matches);\n update();\n mq.addEventListener?.(\"change\", update);\n return () => mq.removeEventListener?.(\"change\", update);\n }, []);\n\n // Swap out the native cursor for ours, and put it back exactly as we found it.\n useEffect(() => {\n if (!fine || !hideNative) return;\n const style = document.createElement(\"style\");\n style.textContent = \".ro-cursor-none, .ro-cursor-none * { cursor: none !important; }\";\n document.head.appendChild(style);\n document.documentElement.classList.add(\"ro-cursor-none\");\n return () => {\n document.documentElement.classList.remove(\"ro-cursor-none\");\n style.remove();\n };\n }, [fine, hideNative]);\n\n useEffect(() => {\n if (!fine) return;\n const root = rootRef.current;\n const dot = dotRef.current;\n const ring = ringRef.current;\n if (!root || !dot || !ring) return;\n\n // An invalid selector would throw on every single pointerover, so test it once.\n let safeSelector = selector;\n try {\n document.querySelector(selector);\n } catch {\n safeSelector = \"\";\n }\n\n // Viewport coords of the pointer; ring position and hover amount are sprung.\n let px = -9999;\n let py = -9999;\n let rx = -9999;\n let ry = -9999;\n let vx = 0;\n let vy = 0;\n let hover = 0;\n let hoverV = 0;\n let hoverTarget = 0;\n let seen = false;\n let raf = 0;\n let running = false;\n\n const step = () => {\n // Live rect: if an ancestor transform has captured our fixed container,\n // this keeps the cursor glued to the real pointer anyway.\n const r = root.getBoundingClientRect();\n\n if (reduced) {\n rx = px;\n ry = py;\n vx = 0;\n vy = 0;\n hover = hoverTarget;\n hoverV = 0;\n } else {\n vx = (vx + (px - rx) * stiffness) * damping;\n vy = (vy + (py - ry) * stiffness) * damping;\n rx += vx;\n ry += vy;\n hoverV = (hoverV + (hoverTarget - hover) * stiffness) * damping;\n hover += hoverV;\n }\n\n const scale = 1 + (hoverScale - 1) * hover;\n dot.style.transform = `translate3d(${(px - r.left).toFixed(2)}px, ${(py - r.top).toFixed(2)}px, 0) translate(-50%, -50%)`;\n ring.style.transform = `translate3d(${(rx - r.left).toFixed(2)}px, ${(ry - r.top).toFixed(2)}px, 0) translate(-50%, -50%) scale(${scale.toFixed(3)})`;\n ring.style.opacity = (0.55 + 0.45 * Math.max(0, Math.min(1, hover))).toFixed(3);\n\n const moving =\n Math.abs(px - rx) > 0.05 ||\n Math.abs(py - ry) > 0.05 ||\n Math.abs(vx) > 0.05 ||\n Math.abs(vy) > 0.05 ||\n Math.abs(hoverTarget - hover) > 0.002 ||\n Math.abs(hoverV) > 0.002;\n\n if (moving) raf = requestAnimationFrame(step);\n else running = false;\n };\n\n const ensure = () => {\n if (!running) {\n running = true;\n raf = requestAnimationFrame(step);\n }\n };\n\n const testHover = (node: EventTarget | Element | null) => {\n if (!safeSelector) return;\n hoverTarget = node instanceof Element && node.closest(safeSelector) ? 1 : 0;\n };\n\n const onMove = (e: PointerEvent) => {\n px = e.clientX;\n py = e.clientY;\n if (!seen) {\n // First sighting: drop the ring on the pointer instead of flying it in\n // from the corner, then fade the pair up.\n seen = true;\n rx = px;\n ry = py;\n root.style.opacity = \"1\";\n }\n ensure();\n };\n const onOver = (e: PointerEvent) => {\n testHover(e.target);\n ensure();\n };\n const onScroll = () => {\n if (!seen) return;\n testHover(document.elementFromPoint(px, py));\n ensure();\n };\n const onLeave = (e: PointerEvent) => {\n if (e.relatedTarget) return;\n root.style.opacity = \"0\";\n seen = false;\n hoverTarget = 0;\n ensure();\n };\n const onBlur = () => {\n root.style.opacity = \"0\";\n seen = false;\n };\n\n document.addEventListener(\"pointermove\", onMove, { passive: true });\n document.addEventListener(\"pointerover\", onOver, { passive: true });\n document.addEventListener(\"pointerout\", onLeave, { passive: true });\n window.addEventListener(\"scroll\", onScroll, { passive: true });\n window.addEventListener(\"blur\", onBlur);\n ensure();\n\n return () => {\n cancelAnimationFrame(raf);\n document.removeEventListener(\"pointermove\", onMove);\n document.removeEventListener(\"pointerover\", onOver);\n document.removeEventListener(\"pointerout\", onLeave);\n window.removeEventListener(\"scroll\", onScroll);\n window.removeEventListener(\"blur\", onBlur);\n };\n }, [fine, reduced, stiffness, damping, hoverScale, selector]);\n\n if (!fine) return null;\n\n return (\n \n \n \n \n );\n}\n" + } + ], + "meta": { + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "spring", + "blend" + ] + } +} diff --git a/public/r/liquid-metal.json b/public/r/liquid-metal.json new file mode 100644 index 0000000..8f0bbeb --- /dev/null +++ b/public/r/liquid-metal.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "liquid-metal", + "type": "registry:component", + "title": "Liquid Metal", + "description": "A molten chrome surface that flows, catches light and dents away from the pointer. Two rounds of domain-warped FBM drive a height field; the normal is taken from finite differences each frame and lit with diffuse, specular and swept polish bands.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/liquid-metal.tsx", + "type": "registry:component", + "target": "components/reactomega/liquid-metal.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface LiquidMetalProps {\n className?: string;\n /** Highlight tint of the polished surface. @default \"#c9d4ff\" */\n tint?: string;\n /** Colour in the troughs. @default \"#0a0b14\" */\n shadow?: string;\n /** Zoom of the flow pattern — higher is busier. @default 1.5 */\n scale?: number;\n /** Flow speed multiplier. @default 1 */\n speed?: number;\n /** Number of polish bands swept across the surface. @default 5 */\n bands?: number;\n /** How hard the pointer pushes into the metal, 0..1. @default 0.6 */\n push?: number;\n}\n\n/**\n * LiquidMetal — a molten chrome surface that flows, catches light and dents\n * away from the pointer.\n *\n * The height field is two rounds of domain-warped value-noise FBM; the surface\n * normal is taken from finite differences of that field each frame, then lit\n * with a diffuse term, a tight specular, and a swept band pattern derived from\n * the normal — which is what reads as *polished* rather than merely bumpy.\n * Runs on raw WebGL2 with no dependencies, and settles into a single still\n * frame for reduced-motion users.\n */\nexport function LiquidMetal({\n className,\n tint = \"#c9d4ff\",\n shadow = \"#0a0b14\",\n scale = 1.5,\n speed = 1,\n bands = 5,\n push = 0.6,\n}: LiquidMetalProps) {\n const uniforms = useMemo(\n () => ({\n uTint: hexToRgb(tint),\n uShadow: hexToRgb(shadow),\n uScale: scale,\n uBands: bands,\n uPush: push,\n }),\n [tint, shadow, scale, bands, push],\n );\n\n const { ref, supported } = useShader({\n speed,\n uniforms,\n fragment: FRAG,\n });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat vnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float a = 0.5;\n float v = 0.0;\n for (int i = 0; i < 4; i++) {\n v += a * vnoise(p);\n p = mat2(1.6, 1.2, -1.2, 1.6) * p;\n a *= 0.5;\n }\n return v;\n}\n\n// Two rounds of domain warping — the second warp is what gives the metal its\n// stretched, poured look instead of generic cloud noise.\nfloat field(vec2 p, float t) {\n vec2 q = vec2(fbm(p + vec2(0.0, t * 0.20)), fbm(p + vec2(5.2, 1.3)));\n vec2 r = vec2(fbm(p + 2.1 * q + vec2(1.7, 9.2) - t * 0.15),\n fbm(p + 2.1 * q + vec2(8.3, 2.8) + t * 0.12));\n return fbm(p + 1.7 * r);\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n\n // Pointer dents the surface: push the sample point radially away from it.\n vec2 away = uv - pc;\n float d2 = dot(away, away);\n float dent = uPointer.z * uPush * exp(-d2 * 7.0);\n vec2 p = uv * uScale + normalize(away + 1e-5) * dent;\n\n float t = uTime * 0.4;\n // Wide finite-difference stencil on purpose: sampling the noise too tightly\n // returns per-pixel normals, which read as grey static rather than poured metal.\n float e = 0.030;\n float h = field(p, t);\n vec3 n = normalize(vec3(field(p + vec2(e, 0.0), t) - h, field(p + vec2(0.0, e), t) - h, 0.09));\n\n vec3 L = normalize(vec3(0.45, 0.72, 0.62));\n float diff = max(dot(n, L), 0.0);\n float spec = pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 42.0);\n\n // Swept polish bands read off the normal, not the height — this is the\n // difference between \"chrome\" and \"grey clouds\".\n float sweep = 0.5 + 0.5 * sin(n.x * uBands * 1.1 + n.y * uBands * 0.8 + h * uBands * 0.7 - t * 0.9);\n vec3 base = mix(uShadow, uTint, smoothstep(0.10, 0.95, sweep));\n\n vec3 col = base * (0.30 + 0.90 * diff) + spec * 1.35;\n col += (uPointer.z * 0.10) * exp(-d2 * 9.0) * uTint;\n\n // Dither out 8-bit banding in the dark falloff.\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n col *= 1.0 - 0.55 * dot(uv, uv);\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "metal", + "background", + "pointer" + ] + } +} diff --git a/public/r/parallax-layers.json b/public/r/parallax-layers.json new file mode 100644 index 0000000..63a1cf9 --- /dev/null +++ b/public/r/parallax-layers.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "parallax-layers", + "type": "registry:component", + "title": "Parallax Layers", + "description": "Depth layers that drift at different rates as a section crosses the viewport, with optional scale and blur on distant layers. Progress is normalised against the section's own centre, so the composition is neutral exactly when the section is centred.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/parallax-layers.tsx", + "type": "registry:component", + "target": "components/reactomega/parallax-layers.tsx", + "content": "\"use client\";\n\nimport { createContext, useCallback, useContext, useEffect, useMemo, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface ParallaxLayersProps {\n children: React.ReactNode;\n /** Extra classes for the section. @default undefined */\n className?: string;\n /** Drift of a `depth={1}` layer at the extremes of the pass, in px. @default 120 */\n range?: number;\n /** Axis the layers drift along. @default \"y\" */\n axis?: \"y\" | \"x\";\n /** Hold the drift at its extremes once the section has left the viewport. @default true */\n clamp?: boolean;\n}\n\nexport interface ParallaxLayerProps {\n children: React.ReactNode;\n /** Extra classes for the layer. @default undefined */\n className?: string;\n /** 0 is locked to the page, 1 drifts the full `range`. Values above 1 are allowed. @default 0.3 */\n depth?: number;\n /** Extra scale at `depth={1}`, e.g. 0.08 to over-size drifting layers so no edge is exposed. @default 0 */\n scale?: number;\n /** Blur at `depth={1}`, in px, scaled by depth. 0 disables the filter entirely. @default 0 */\n blur?: number;\n}\n\ninterface Registration {\n el: HTMLElement;\n depth: number;\n scale: number;\n blur: number;\n}\n\nconst ParallaxContext = createContext<{ register: (r: Registration) => () => void } | null>(null);\n\n/**\n * ParallaxLayers — a section whose children drift at different rates as it passes through\n * the viewport, so nearer things outrun farther ones and the band reads as having depth.\n *\n * Progress is signed and derived from the container's own position rather than raw\n * `scrollY`: `(viewportCentre - sectionCentre) / ((viewportHeight + sectionHeight) / 2)`.\n * That is exactly -1 when the section is about to enter, **0 when the section is centred**,\n * and +1 when it has just left — so the layers sit at their neutral, as-authored positions\n * at the moment the reader is actually looking at them, and the effect never depends on\n * where the section happens to live in the document.\n *\n * Layers register their DOM node with the container through context rather than receiving\n * progress as a prop, so the scroll loop writes `transform` straight to each node and the\n * React tree never re-renders while scrolling. The container reads one rect per frame,\n * coalesced from a passive scroll listener via a dirty animation frame, and re-measures\n * through a ResizeObserver.\n *\n * Reduced-motion users get every layer at its neutral position in normal flow — nothing is\n * displaced, blurred or scaled.\n */\nexport function ParallaxLayers({\n children,\n className,\n range = 120,\n axis = \"y\",\n clamp = true,\n}: ParallaxLayersProps) {\n const ref = useRef(null);\n const registry = useRef>(new Set());\n const requestRef = useRef<() => void>(() => {});\n const reduced = usePrefersReducedMotion();\n\n const register = useCallback((entry: Registration) => {\n registry.current.add(entry);\n requestRef.current();\n return () => {\n registry.current.delete(entry);\n entry.el.style.transform = \"\";\n entry.el.style.filter = \"\";\n };\n }, []);\n\n const ctx = useMemo(() => ({ register }), [register]);\n\n useEffect(() => {\n const clear = () => {\n registry.current.forEach((entry) => {\n entry.el.style.transform = \"\";\n entry.el.style.filter = \"\";\n });\n };\n\n if (reduced) {\n clear();\n return;\n }\n\n const el = ref.current;\n if (!el) return;\n\n let raf = 0;\n\n const update = () => {\n raf = 0;\n const rect = el.getBoundingClientRect();\n const vh = window.innerHeight || 1;\n const span = (vh + rect.height) / 2;\n if (span <= 0) return;\n\n let p = (vh / 2 - (rect.top + rect.height / 2)) / span;\n if (clamp) p = p < -1 ? -1 : p > 1 ? 1 : p;\n\n registry.current.forEach((entry) => {\n const shift = p * range * entry.depth;\n const move =\n axis === \"x\"\n ? `translate3d(${shift.toFixed(2)}px, 0, 0)`\n : `translate3d(0, ${shift.toFixed(2)}px, 0)`;\n const s = 1 + entry.depth * entry.scale;\n entry.el.style.transform = entry.scale !== 0 ? `${move} scale(${s.toFixed(4)})` : move;\n if (entry.blur > 0) entry.el.style.filter = `blur(${(entry.depth * entry.blur).toFixed(2)}px)`;\n });\n };\n\n const request = () => {\n if (!raf) raf = requestAnimationFrame(update);\n };\n\n requestRef.current = request;\n update();\n window.addEventListener(\"scroll\", request, { passive: true });\n window.addEventListener(\"resize\", request);\n const ro = new ResizeObserver(request);\n ro.observe(el);\n\n return () => {\n cancelAnimationFrame(raf);\n requestRef.current = () => {};\n window.removeEventListener(\"scroll\", request);\n window.removeEventListener(\"resize\", request);\n ro.disconnect();\n clear();\n };\n }, [reduced, range, axis, clamp]);\n\n return (\n \n
\n {children}\n
\n
\n );\n}\n\n/**\n * ParallaxLayer — one plane inside a `ParallaxLayers` section. It registers its node with\n * the parent on mount and is driven imperatively from there, so changing `depth` re-registers\n * and takes effect on the next frame. Used outside a `ParallaxLayers` it renders as a plain\n * block with no motion, which is also exactly what reduced-motion users get.\n */\nexport function ParallaxLayer({ children, className, depth = 0.3, scale = 0, blur = 0 }: ParallaxLayerProps) {\n const ref = useRef(null);\n const ctx = useContext(ParallaxContext);\n\n useEffect(() => {\n const el = ref.current;\n if (!el || !ctx) return;\n return ctx.register({ el, depth, scale, blur });\n }, [ctx, depth, scale, blur]);\n\n return (\n
\n {children}\n
\n );\n}\n" + } + ], + "meta": { + "category": "scroll", + "tags": [ + "scroll", + "parallax", + "depth", + "layers" + ] + } +} diff --git a/public/r/pixel-trail.json b/public/r/pixel-trail.json new file mode 100644 index 0000000..d45411d --- /dev/null +++ b/public/r/pixel-trail.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "pixel-trail", + "type": "registry:component", + "title": "Pixel Trail", + "description": "A canvas grid whose cells ignite under the pointer and decay exponentially into a dissolving comet trail. The path between the previous and current position is walked at sub-cell steps, so a fast flick still lights a continuous line.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/pixel-trail.tsx", + "type": "registry:component", + "target": "components/reactomega/pixel-trail.tsx", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface PixelTrailProps {\n className?: string;\n /** Edge length of one cell in px. @default 12 */\n pixelSize?: number;\n /** Lit pixel color. @default \"#7c5cff\" */\n color?: string;\n /** Brightness kept per frame, 0..1 — higher leaves a longer comet. @default 0.9 */\n decay?: number;\n /** Bloom strength around lit pixels; 0 disables it. @default 0.6 */\n glow?: number;\n /** Space between cells in px. @default 2 */\n gap?: number;\n}\n\n/**\n * PixelTrail — a grid of square cells that ignite to full brightness as the\n * pointer crosses them and then decay exponentially, leaving a dissolving comet\n * of pixels behind the cursor.\n *\n * Cells are lit by walking the segment between the previous and current pointer\n * position at half-cell steps rather than by lighting whatever sits under the\n * latest event. A mouse reporting at 60Hz can travel hundreds of px between two\n * events, and per-event lighting turns a fast flick into a dotted line; segment\n * interpolation keeps the path continuous at any speed, with the sample count\n * capped so a window-wide jump cannot spike a frame.\n *\n * Brightness lives in a single Float32Array multiplied by `decay` each frame —\n * at the default that is a ~110ms half life. Only cells above the visible\n * threshold are painted, in two passes (an additive bloom pass, then the crisp\n * cores) so the composite mode is set twice per frame instead of twice per pixel.\n * The loop stops the moment the grid is dark and restarts on the next move.\n * Reduced motion and coarse pointers get the same grid rendered once, unlit and\n * static, so the surface still reads as a textured panel.\n */\nexport function PixelTrail({\n className,\n pixelSize = 12,\n color = \"#7c5cff\",\n decay = 0.9,\n glow = 0.6,\n gap = 2,\n}: PixelTrailProps) {\n const ref = useRef(null);\n const [fine, setFine] = useState(false);\n const reduced = usePrefersReducedMotion();\n\n useEffect(() => {\n if (typeof window === \"undefined\" || !window.matchMedia) return;\n const mq = window.matchMedia(\"(pointer: fine)\");\n const update = () => setFine(mq.matches);\n update();\n mq.addEventListener?.(\"change\", update);\n return () => mq.removeEventListener?.(\"change\", update);\n }, []);\n\n useEffect(() => {\n const canvas = ref.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n const cell = Math.max(1, pixelSize);\n const step = cell + Math.max(0, gap);\n const fade = Math.min(0.985, Math.max(0.5, decay));\n const still = reduced || !fine;\n\n let w = 0;\n let h = 0;\n let cols = 0;\n let rows = 0;\n let buf = new Float32Array(0);\n let raf = 0;\n let running = false;\n let lastX = -1;\n let lastY = -1;\n\n const paintStatic = () => {\n ctx.clearRect(0, 0, w, h);\n ctx.globalCompositeOperation = \"source-over\";\n ctx.globalAlpha = 0.1;\n ctx.fillStyle = color;\n for (let y = 0; y < rows; y++) {\n for (let x = 0; x < cols; x++) ctx.fillRect(x * step, y * step, cell, cell);\n }\n ctx.globalAlpha = 1;\n };\n\n const resize = () => {\n const r = canvas.getBoundingClientRect();\n w = Math.max(1, r.width);\n h = Math.max(1, r.height);\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(w * dpr);\n canvas.height = Math.round(h * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n cols = Math.max(1, Math.ceil(w / step));\n rows = Math.max(1, Math.ceil(h / step));\n buf = new Float32Array(cols * rows);\n if (still) paintStatic();\n };\n\n const draw = () => {\n ctx.clearRect(0, 0, w, h);\n ctx.fillStyle = color;\n\n // Pass 1 — additive bloom, laid down under the cores.\n if (glow > 0) {\n ctx.globalCompositeOperation = \"lighter\";\n const pad = cell * 0.55;\n for (let i = 0; i < buf.length; i++) {\n const v = buf[i];\n if (v < 0.02) continue;\n ctx.globalAlpha = Math.min(1, v * 0.3 * glow);\n ctx.fillRect((i % cols) * step - pad, Math.floor(i / cols) * step - pad, cell + pad * 2, cell + pad * 2);\n }\n }\n\n // Pass 2 — crisp cores, plus the decay sweep for every cell.\n ctx.globalCompositeOperation = \"source-over\";\n let lit = 0;\n for (let i = 0; i < buf.length; i++) {\n const v = buf[i];\n if (v < 0.02) {\n if (v !== 0) buf[i] = 0;\n continue;\n }\n lit++;\n ctx.globalAlpha = Math.min(1, v);\n ctx.fillRect((i % cols) * step, Math.floor(i / cols) * step, cell, cell);\n buf[i] = v * fade;\n }\n\n ctx.globalAlpha = 1;\n if (lit > 0) raf = requestAnimationFrame(draw);\n else running = false;\n };\n\n const ensure = () => {\n if (still || running) return;\n running = true;\n raf = requestAnimationFrame(draw);\n };\n\n const ignite = (x0: number, y0: number, x1: number, y1: number) => {\n const dist = Math.hypot(x1 - x0, y1 - y0);\n const samples = Math.min(240, Math.max(1, Math.ceil(dist / (step * 0.5))));\n for (let i = 0; i <= samples; i++) {\n const t = i / samples;\n const cx = Math.floor((x0 + (x1 - x0) * t) / step);\n const cy = Math.floor((y0 + (y1 - y0) * t) / step);\n if (cx < 0 || cy < 0 || cx >= cols || cy >= rows) continue;\n buf[cy * cols + cx] = 1;\n }\n };\n\n const onMove = (e: PointerEvent) => {\n if (still) return;\n const r = canvas.getBoundingClientRect();\n const x = e.clientX - r.left;\n const y = e.clientY - r.top;\n if (lastX < 0) ignite(x, y, x, y);\n else ignite(lastX, lastY, x, y);\n lastX = x;\n lastY = y;\n ensure();\n };\n // Break the path on both enter and leave so re-entering the panel does not\n // draw a streak across it from wherever the pointer left.\n const breakPath = () => {\n lastX = -1;\n lastY = -1;\n };\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n canvas.addEventListener(\"pointermove\", onMove, { passive: true });\n canvas.addEventListener(\"pointerenter\", breakPath);\n canvas.addEventListener(\"pointerleave\", breakPath);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n canvas.removeEventListener(\"pointermove\", onMove);\n canvas.removeEventListener(\"pointerenter\", breakPath);\n canvas.removeEventListener(\"pointerleave\", breakPath);\n };\n }, [pixelSize, color, decay, glow, gap, reduced, fine]);\n\n return ;\n}\n" + } + ], + "meta": { + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "canvas", + "pixels", + "trail" + ] + } +} diff --git a/public/r/scroll-scene.json b/public/r/scroll-scene.json new file mode 100644 index 0000000..ac10869 --- /dev/null +++ b/public/r/scroll-scene.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "scroll-scene", + "type": "registry:component", + "title": "Scroll Scene", + "description": "Pins a section and exposes its scrub progress from 0 to 1 through a render prop, plus a live CSS custom property and a per-frame callback. Travel is derived from the measured spacer and pinned heights, so there is no viewport constant to get wrong.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/scroll-scene.tsx", + "type": "registry:component", + "target": "components/reactomega/scroll-scene.tsx", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface ScrollSceneProps {\n /** Render prop. Receives the scrub progress: 0 the moment the scene pins, 1 when it unpins. */\n children: (progress: number) => React.ReactNode;\n /** Extra classes for the pinned viewport — the visible surface, not the tall outer spacer. @default undefined */\n className?: string;\n /** Extra viewports of scroll to pin for. 1 means one full screen of scrubbing. @default 1.5 */\n length?: number;\n /** Progress handed to children when the user prefers reduced motion. @default 1 */\n staticProgress?: number;\n /** Inertia on the scrub, 0 to 1. 0 tracks scroll exactly; higher glides in behind it. @default 0.72 */\n smoothing?: number;\n /** Called every frame with the raw, unrounded progress. The zero-re-render escape hatch. @default undefined */\n onProgress?: (progress: number) => void;\n}\n\n/**\n * ScrollScene — pins a section for a set distance and hands its children the scrub\n * progress, so anything at all can be driven by scroll position.\n *\n * A tall outer spacer (`100vh * (1 + length)`) holds the scroll distance; inside it a\n * `position: sticky; top: 0` viewport does the pinning natively, which is smoother than\n * a JS-driven fixed element and does not fight iOS momentum scrolling. Progress is\n * `-outer.top / (outer.height - pinned.height)` — deriving the travel from the two\n * measured heights rather than assuming `innerHeight` is what keeps it exact, because the\n * pin genuinely ends when the sticky element's bottom meets the spacer's bottom, and mobile\n * `100vh` is not the visible viewport. Off-by-one-viewport is the classic bug here and this\n * form has no viewport constant in it at all.\n *\n * Three ways out, in increasing cost: the pinned element carries a live `--ro-progress`\n * custom property (free, pure CSS); `onProgress` fires with the raw value every frame (no\n * re-render); and the render prop re-renders, but only on a change of 1/1000, so a frame\n * that does not move the value does not cost a render. Smoothing is a frame-rate-independent\n * exponential (`1 - smoothing^(dt*60)`), and the loop stops itself once it has settled.\n *\n * Reduced motion removes the spacer and the pin entirely: the section takes its natural\n * height in normal flow and the children are rendered once at `staticProgress`, so nothing\n * is ever left off-screen or invisible. Keep focusable elements visible across the whole\n * range — a link that only exists at progress 0.5 is a keyboard trap.\n */\nexport function ScrollScene({\n children,\n className,\n length = 1.5,\n staticProgress = 1,\n smoothing = 0.72,\n onProgress,\n}: ScrollSceneProps) {\n const outerRef = useRef(null);\n const pinRef = useRef(null);\n const callbackRef = useRef(onProgress);\n const [progress, setProgress] = useState(0);\n const reduced = usePrefersReducedMotion();\n\n useEffect(() => {\n callbackRef.current = onProgress;\n });\n\n useEffect(() => {\n if (!reduced) return;\n pinRef.current?.style.setProperty(\"--ro-progress\", staticProgress.toFixed(5));\n callbackRef.current?.(staticProgress);\n }, [reduced, staticProgress]);\n\n useEffect(() => {\n if (reduced) return;\n const outer = outerRef.current;\n const pin = pinRef.current;\n if (!outer || !pin) return;\n\n const inertia = smoothing < 0 ? 0 : smoothing > 0.99 ? 0.99 : smoothing;\n let raf = 0;\n let running = false;\n let dirty = false;\n let primed = false;\n let last = 0;\n let current = 0;\n let target = 0;\n\n const measure = () => {\n const outerRect = outer.getBoundingClientRect();\n const travel = outerRect.height - pin.getBoundingClientRect().height;\n if (travel <= 0) {\n target = outerRect.top <= 0 ? 1 : 0;\n return;\n }\n const p = -outerRect.top / travel;\n target = p < 0 ? 0 : p > 1 ? 1 : p;\n };\n\n const commit = (value: number) => {\n pin.style.setProperty(\"--ro-progress\", value.toFixed(5));\n callbackRef.current?.(value);\n const quantised = Math.round(value * 1000) / 1000;\n setProgress((prev) => (prev === quantised ? prev : quantised));\n };\n\n const step = (now: number) => {\n // Layout reads happen here, never in the scroll handler.\n if (dirty) {\n dirty = false;\n measure();\n }\n const dt = primed ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n last = now;\n primed = true;\n\n const k = inertia > 0 ? 1 - Math.pow(inertia, dt * 60) : 1;\n current += (target - current) * k;\n if (Math.abs(target - current) < 0.0002) current = target;\n commit(current);\n\n if (current !== target || dirty) {\n raf = requestAnimationFrame(step);\n } else {\n running = false;\n raf = 0;\n }\n };\n\n const ensure = () => {\n if (running) return;\n running = true;\n primed = false;\n raf = requestAnimationFrame(step);\n };\n\n const invalidate = () => {\n dirty = true;\n ensure();\n };\n\n measure();\n current = target;\n commit(current);\n\n window.addEventListener(\"scroll\", invalidate, { passive: true });\n window.addEventListener(\"resize\", invalidate);\n const ro = new ResizeObserver(invalidate);\n ro.observe(outer);\n ro.observe(pin);\n\n return () => {\n cancelAnimationFrame(raf);\n window.removeEventListener(\"scroll\", invalidate);\n window.removeEventListener(\"resize\", invalidate);\n ro.disconnect();\n };\n }, [reduced, smoothing]);\n\n const value = reduced ? staticProgress : progress;\n\n return (\n \n \n {children(value)}\n \n {!reduced && (\n \n )}\n \n );\n}\n" + } + ], + "meta": { + "category": "scroll", + "tags": [ + "scroll", + "pin", + "scrub", + "progress", + "render-prop" + ] + } +} diff --git a/public/r/scroll-stack.json b/public/r/scroll-stack.json new file mode 100644 index 0000000..f2d3653 --- /dev/null +++ b/public/r/scroll-stack.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "scroll-stack", + "type": "registry:component", + "title": "Scroll Stack", + "description": "Cards that stack into a deck as you scroll, pinned with native sticky positioning. Each card's depth is the running sum of how far the later cards have travelled, so the scale, dim and blur are continuous rather than stepped, and the front card always stays clean.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/scroll-stack.tsx", + "type": "registry:component", + "target": "components/reactomega/scroll-stack.tsx", + "content": "\"use client\";\n\nimport { Children, useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface ScrollStackProps {\n children: React.ReactNode;\n /** Extra classes for the stack container. @default undefined */\n className?: string;\n /** Distance from the top of the viewport that each card pins at, in px. @default 80 */\n offset?: number;\n /** Scale removed per card resting on top of this one. @default 0.04 */\n scaleStep?: number;\n /** Opacity removed per card resting on top of this one. @default 0.14 */\n opacityStep?: number;\n /** Blur added per card resting on top of this one, in px. 0 disables the filter entirely. @default 2 */\n blur?: number;\n /** Vertical space between cards while they are still in flow, in px. @default 64 */\n gap?: number;\n}\n\n/** Cards deeper than this stop receding, so a long list can never invert the scale. */\nconst MAX_DEPTH = 4;\n\nconst clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);\n\n/**\n * ScrollStack — cards that collect into a deck as you scroll. Every card is pinned\n * with native `position: sticky` (never JS-driven fixed positioning, so it survives\n * iOS momentum scrolling), and each one recedes by the number of cards already\n * resting on top of it.\n *\n * The depth of a card is the running sum of how far every later card has travelled\n * to its rest position, measured live from the sticky wrappers' own rects — so it is\n * fractional and continuous rather than stepped. A receding card scales from its top\n * edge and lifts by exactly the height it loses (`height * depth * scaleStep`), which\n * pushes its top edge above the card in front by that same amount and produces the\n * fanned deck without any magic peek constant. The last card has nothing on top of it,\n * so it always renders clean.\n *\n * The transform lives on an inner element and the rect is read from the sticky wrapper,\n * so the measurement can never be contaminated by the transform it produces. Reads for\n * every card happen in one pass before any write, and scroll events only raise a dirty\n * flag that is drained on the next animation frame.\n *\n * Give each child an opaque background — they overlap by design. Reduced-motion users\n * get the same cards unpinned, unscaled and in normal document flow.\n */\nexport function ScrollStack({\n children,\n className,\n offset = 80,\n scaleStep = 0.04,\n opacityStep = 0.14,\n blur = 2,\n gap = 64,\n}: ScrollStackProps) {\n const containerRef = useRef(null);\n const wrapRefs = useRef<(HTMLDivElement | null)[]>([]);\n const innerRefs = useRef<(HTMLDivElement | null)[]>([]);\n const reduced = usePrefersReducedMotion();\n const items = Children.toArray(children);\n\n useEffect(() => {\n const inners = innerRefs.current;\n const reset = () => {\n inners.forEach((el) => {\n if (!el) return;\n el.style.transform = \"\";\n el.style.opacity = \"\";\n el.style.filter = \"\";\n });\n };\n\n if (reduced) {\n reset();\n return;\n }\n\n const container = containerRef.current;\n if (!container) return;\n\n let raf = 0;\n\n const update = () => {\n raf = 0;\n const wraps = wrapRefs.current;\n const n = wraps.length;\n if (!n) return;\n\n // Read pass — every rect first, so no write can force an extra layout.\n const rects: (DOMRect | null)[] = new Array(n);\n for (let i = 0; i < n; i++) {\n const w = wraps[i];\n rects[i] = w ? w.getBoundingClientRect() : null;\n }\n\n // How far each card has travelled from \"sitting below its predecessor\" to\n // \"landed on the stack\". 0 when its top is one gap below the previous card's\n // bottom, 1 when the two tops coincide at the pin line.\n const arrival: number[] = new Array(n).fill(0);\n for (let j = 1; j < n; j++) {\n const prev = rects[j - 1];\n const cur = rects[j];\n if (!prev || !cur) continue;\n const travel = prev.height + gap;\n if (travel <= 0) continue;\n arrival[j] = clamp01((prev.bottom + gap - cur.top) / travel);\n }\n\n // Write pass — depth of card i is the sum of every arrival after it.\n let acc = 0;\n for (let i = n - 1; i >= 0; i--) {\n const inner = inners[i];\n const rect = rects[i];\n const depth = acc < MAX_DEPTH ? acc : MAX_DEPTH;\n acc += arrival[i];\n if (!inner) continue;\n\n if (depth < 0.001) {\n inner.style.transform = \"\";\n inner.style.opacity = \"\";\n if (blur > 0) inner.style.filter = \"\";\n continue;\n }\n\n const scale = 1 - depth * scaleStep;\n const lift = depth * (rect ? rect.height : 0) * scaleStep;\n inner.style.transform = `translate3d(0, ${(-lift).toFixed(2)}px, 0) scale(${scale.toFixed(4)})`;\n inner.style.opacity = Math.max(0, 1 - depth * opacityStep).toFixed(3);\n if (blur > 0) inner.style.filter = `blur(${(depth * blur).toFixed(2)}px)`;\n }\n };\n\n const request = () => {\n if (!raf) raf = requestAnimationFrame(update);\n };\n\n update();\n window.addEventListener(\"scroll\", request, { passive: true });\n window.addEventListener(\"resize\", request);\n const ro = new ResizeObserver(request);\n ro.observe(container);\n\n return () => {\n cancelAnimationFrame(raf);\n window.removeEventListener(\"scroll\", request);\n window.removeEventListener(\"resize\", request);\n ro.disconnect();\n reset();\n };\n }, [reduced, offset, scaleStep, opacityStep, blur, gap, items.length]);\n\n return (\n
\n {items.map((child, i) => (\n {\n wrapRefs.current[i] = el;\n }}\n className={reduced ? \"relative\" : \"sticky\"}\n style={reduced ? undefined : { top: `${offset}px` }}\n >\n {\n innerRefs.current[i] = el;\n }}\n className={cn(\"origin-top\", !reduced && \"will-change-transform\")}\n >\n {child}\n
\n \n ))}\n \n );\n}\n" + } + ], + "meta": { + "category": "scroll", + "tags": [ + "scroll", + "sticky", + "cards", + "stack" + ] + } +} diff --git a/public/r/scroll-velocity.json b/public/r/scroll-velocity.json new file mode 100644 index 0000000..aa5875c --- /dev/null +++ b/public/r/scroll-velocity.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "scroll-velocity", + "type": "registry:component", + "title": "Scroll Velocity", + "description": "A seamless row that drifts at a base speed but is driven by scroll velocity, skewing as it lurches and easing back to baseline when you stop. Velocity is smoothed with a time-constant decay, so the feel is identical at 60Hz and 120Hz.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/scroll-velocity.tsx", + "type": "registry:component", + "target": "components/reactomega/scroll-velocity.tsx", + "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\nexport interface ScrollVelocityProps {\n children: React.ReactNode;\n /** Extra classes for the clipping row. @default undefined */\n className?: string;\n /** Baseline drift in px per second, running even when the page is still. @default 40 */\n baseSpeed?: number;\n /** Fraction of the scroll velocity (px/s) added to the drift. @default 0.75 */\n velocityFactor?: number;\n /** Degrees of skew per 1000 px/s of scroll velocity. @default 5 */\n skew?: number;\n /** Hard cap on the skew, in degrees. @default 12 */\n maxSkew?: number;\n /** Direction of the baseline drift. @default \"left\" */\n direction?: \"left\" | \"right\";\n}\n\n/** Space between repeats, in px. Also the seam gap, so the loop period is width + this. */\nconst SEAM = 32;\n/** Scroll velocity is clamped here (px/s) so a flick or a scroll-to-top cannot slingshot the row. */\nconst MAX_VELOCITY = 2600;\n\n/**\n * ScrollVelocity — a row of repeated content that always drifts at a baseline speed but\n * is shoved along by how fast you are scrolling, skewing into the lurch and easing back\n * to its baseline when you stop.\n *\n * Velocity is the change in `scrollY` divided by the real frame delta, then smoothed with\n * a time-constant exponential filter (`1 - e^(-dt/tau)`) rather than a fixed per-frame\n * lerp, so the feel is identical at 60Hz and 120Hz. It is clamped before use, which is\n * what stops a scroll-to-top or a trackpad flick from launching the row across the screen.\n * The smoothing is what makes it *ease* back rather than snap: when scrolling stops the\n * instantaneous velocity is 0 and the filter decays into it.\n *\n * The loop is seamless in the marquee way — the track is duplicated and the offset is\n * wrapped with a modulo of one repeat's period (measured width plus the seam gap, taken\n * from a ResizeObserver so the transform on the row can never contaminate it). Because the\n * offset is normalised into [0, period) every frame, scroll velocity may reverse the row\n * past its baseline direction without ever exposing an edge.\n *\n * The animation frame only runs while the row is on screen, and reduced-motion users get a\n * single static, wrapped copy — all content readable, nothing clipped or in motion.\n */\nexport function ScrollVelocity({\n children,\n className,\n baseSpeed = 40,\n velocityFactor = 0.75,\n skew = 5,\n maxSkew = 12,\n direction = \"left\",\n}: ScrollVelocityProps) {\n const rootRef = useRef(null);\n const rowRef = useRef(null);\n const copyRef = useRef(null);\n const reduced = usePrefersReducedMotion();\n\n useEffect(() => {\n if (reduced) return;\n const root = rootRef.current;\n const row = rowRef.current;\n const copy = copyRef.current;\n if (!root || !row || !copy) return;\n\n const dir = direction === \"right\" ? -1 : 1;\n let period = 0;\n let x = 0;\n let velocity = 0;\n let last = 0;\n let lastY = window.scrollY;\n let raf = 0;\n\n const step = (now: number) => {\n // Clamped so a background tab or a long frame cannot teleport the row.\n const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;\n last = now;\n\n const y = window.scrollY;\n const instant = dt > 0 ? (y - lastY) / dt : 0;\n lastY = y;\n\n const k = 1 - Math.exp(-dt / 0.09);\n velocity += (instant - velocity) * k;\n if (velocity > MAX_VELOCITY) velocity = MAX_VELOCITY;\n else if (velocity < -MAX_VELOCITY) velocity = -MAX_VELOCITY;\n\n x += (baseSpeed * dir + velocity * velocityFactor) * dt;\n if (period > 0) x = ((x % period) + period) % period;\n\n let deg = (velocity / 1000) * skew;\n if (deg > maxSkew) deg = maxSkew;\n else if (deg < -maxSkew) deg = -maxSkew;\n\n row.style.transform = `translate3d(${(-x).toFixed(2)}px, 0, 0) skewX(${deg.toFixed(2)}deg)`;\n raf = requestAnimationFrame(step);\n };\n\n const start = () => {\n if (raf) return;\n // Re-prime the clock and the scroll anchor, otherwise resuming produces one\n // enormous fake velocity spike from everything that happened while paused.\n last = 0;\n lastY = window.scrollY;\n raf = requestAnimationFrame(step);\n };\n const stop = () => {\n if (!raf) return;\n cancelAnimationFrame(raf);\n raf = 0;\n };\n\n const ro = new ResizeObserver((entries) => {\n period = entries[0].contentRect.width + SEAM;\n });\n ro.observe(copy);\n\n const io = new IntersectionObserver(\n (entries) => {\n if (entries[0].isIntersecting) start();\n else stop();\n },\n { rootMargin: \"160px 0px\" },\n );\n io.observe(root);\n\n return () => {\n stop();\n ro.disconnect();\n io.disconnect();\n row.style.transform = \"\";\n };\n }, [reduced, baseSpeed, velocityFactor, skew, maxSkew, direction]);\n\n if (reduced) {\n return (\n
\n
\n {children}\n
\n
\n );\n }\n\n return (\n
\n \n {[0, 1].map((k) => (\n \n {children}\n
\n ))}\n \n \n );\n}\n" + } + ], + "meta": { + "category": "scroll", + "tags": [ + "scroll", + "velocity", + "marquee", + "skew" + ] + } +} diff --git a/public/r/thin-film.json b/public/r/thin-film.json new file mode 100644 index 0000000..7acb234 --- /dev/null +++ b/public/r/thin-film.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "thin-film", + "type": "registry:component", + "title": "Thin Film", + "description": "Iridescent soap-film sheen computed from real thin-film interference: phase is evaluated at three representative RGB wavelengths over a wobbling thickness field, so the fringe order is physical rather than a hue rotation. The pointer thins the film and shifts the bands.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/thin-film.tsx", + "type": "registry:component", + "target": "components/reactomega/thin-film.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface ThinFilmProps {\n className?: string;\n /** Mean film thickness in nanometres — picks which interference order you see. @default 380 */\n thickness?: number;\n /** Drift speed of the film. @default 1 */\n speed?: number;\n /** 0 collapses the fringes to grey, 1 is the raw interference colour. @default 0.68 */\n saturation?: number;\n /** Zoom of the thickness field — higher gives tighter fringes. @default 1.9 */\n scale?: number;\n /** Colour the whole film is biased toward. @default \"#7b6cff\" */\n tint?: string;\n}\n\n/**\n * ThinFilm — a soap-film / oil-slick sheen whose colour is computed from actual\n * thin-film interference rather than a rotated hue wheel.\n *\n * A warped FBM (crossed with two slow sine trains so the fringes stay crisp)\n * gives a thickness field in nanometres. The surface normal comes from finite\n * differences of that field, Snell's law bends the view ray to the internal\n * angle, and the reflected intensity at three representative wavelengths —\n * 610nm, 550nm, 465nm — is the two-beam result I(λ) = sin²(δ/2) with\n * δ = 4π·n·d·cosθ/λ + π, the π accounting for the hard-surface phase flip.\n * Because red, green and blue go in and out of phase at different rates, the\n * band order you get is the real one: magenta into gold into cyan, and the\n * colour genuinely shifts with viewing angle. The pointer squeezes the film\n * thinner, which walks every fringe under the cursor.\n */\nexport function ThinFilm({\n className,\n thickness = 380,\n speed = 1,\n saturation = 0.68,\n scale = 1.9,\n tint = \"#7b6cff\",\n}: ThinFilmProps) {\n const uniforms = useMemo(\n () => ({\n uTint: hexToRgb(tint),\n uThickness: thickness,\n uSat: saturation,\n uScale: scale,\n }),\n [tint, thickness, saturation, scale],\n );\n\n const { ref, supported } = useShader({\n speed,\n uniforms,\n fragment: FRAG,\n });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat vnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float a = 0.5;\n float v = 0.0;\n for (int i = 0; i < 4; i++) {\n v += a * vnoise(p);\n p = mat2(1.6, 1.2, -1.2, 1.6) * p;\n a *= 0.5;\n }\n return v;\n}\n\n// Normalised film thickness, 0..1. Warped FBM gives the draining-soap blotches;\n// the sine train reinstates the crisp fringe edges noise alone smears away.\nfloat filmField(vec2 p, float t) {\n vec2 w = vec2(fbm(p + vec2(0.0, t * 0.11)), fbm(p * 1.13 + vec2(4.7, -t * 0.09)));\n float n = fbm(p + 2.3 * w);\n float ripple = 0.5 + 0.5 * sin(p.x * 2.7 + p.y * 1.9 + t * 0.8 + n * 5.0);\n return mix(n, ripple, 0.34);\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n\n float t = uTime * 0.5;\n vec2 p = uv * uScale;\n\n // The pointer presses the film thinner. Fringe order is a function of d, so\n // thinning here does not brighten the film — it walks the bands sideways.\n vec2 rel = uv - pc;\n float press = uPointer.z * exp(-dot(rel, rel) * 9.0);\n\n float e = 0.010;\n float h = filmField(p, t);\n vec3 n = normalize(vec3((h - filmField(p + vec2(e, 0.0), t)) * 2.2,\n (h - filmField(p + vec2(0.0, e), t)) * 2.2, 0.22));\n\n // Snell: the optical path inside the film uses the refracted angle, not the\n // incident one, which is why the fringes crowd toward grazing incidence.\n float ior = 1.42;\n float cosI = clamp(n.z, 0.05, 1.0);\n float sinT = sqrt(max(0.0, 1.0 - cosI * cosI)) / ior;\n float cosT = sqrt(max(0.0, 1.0 - sinT * sinT));\n\n // A narrower thickness swing means fewer fringes across the frame, so the\n // bands stay broad and pastel instead of stacking into a rainbow moiré.\n float d = uThickness * (0.62 + 0.55 * h) * (1.0 - 0.45 * press);\n\n // Two-beam interference at three representative wavelengths.\n const vec3 LAMBDA = vec3(610.0, 550.0, 465.0);\n vec3 delta = (12.566370614 * ior * d * cosT) / LAMBDA + 3.14159265;\n vec3 film = 0.5 - 0.5 * cos(delta);\n\n // Knock the green channel back: unweighted, the middle wavelength peaks alone\n // and the frame reads as a heat map rather than an oil slick.\n film = pow(film, vec3(0.94, 1.18, 1.0));\n float lum = dot(film, vec3(0.2126, 0.7152, 0.0722));\n film = clamp(mix(vec3(lum), film, uSat), 0.0, 1.0);\n film *= mix(vec3(1.0), uTint * 1.9 + 0.12, 0.60);\n\n // Where the film runs thin it reflects almost nothing. That black is what\n // stops the whole frame reading as one flat rainbow smear.\n float density = smoothstep(0.15, 0.72, h) * (0.58 + 0.42 * pow(1.0 - cosI, 2.0));\n vec3 col = uTint * 0.026 + film * density * 0.88;\n\n vec3 L = normalize(vec3(0.35, 0.68, 0.64));\n col += pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 48.0) * 0.50;\n col += uTint * 0.14 * press;\n\n col *= 1.0 - 0.42 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "iridescent", + "interference", + "background" + ] + } +} diff --git a/public/r/use-shader.json b/public/r/use-shader.json new file mode 100644 index 0000000..a98484b --- /dev/null +++ b/public/r/use-shader.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "use-shader", + "type": "registry:hook", + "title": "useShader()", + "description": "A dependency-free raw WebGL2 runtime for full-screen fragment shaders: DPR-clamped sizing, suspension while off-screen, context-loss recovery, eased pointer input, auto-generated uniform declarations, and a single still frame for reduced-motion users.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "hooks/use-shader.ts", + "type": "registry:hook", + "target": "hooks/use-shader.ts", + "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { usePrefersReducedMotion } from \"@/hooks/use-prefers-reduced-motion\";\n\n/** A uniform value: float, vec2, vec3 or vec4. */\nexport type UniformValue = number | readonly [number, number] | readonly [number, number, number] | readonly [number, number, number, number];\n\nexport interface UseShaderOptions {\n /**\n * GLSL ES 3.00 fragment shader body. The hook prepends `#version 300 es`,\n * a highp precision qualifier, the built-in uniforms and `out vec4 fragColor`\n * — so your source starts at your own helpers / `void main()`.\n *\n * Built-ins available to every shader:\n * uniform vec2 uResolution; // drawing-buffer size in device pixels\n * uniform float uTime; // seconds since mount, scaled by `speed`\n * uniform vec3 uPointer; // xy = pointer in device px (y up), z = 0..1 eased hover\n */\n fragment: string;\n /**\n * Custom uniforms. Declarations are generated from the arity of each value,\n * so `{ uTint: [0.48, 0.36, 1] }` emits `uniform vec3 uTint;` automatically.\n * Values are re-read every frame — changing a prop never recompiles.\n */\n uniforms?: Record;\n /** Time multiplier. @default 1 */\n speed?: number;\n /** Device-pixel-ratio ceiling. Caps GPU cost on retina displays. @default 2 */\n dpr?: number;\n /** Track the pointer and feed `uPointer`. @default true */\n pointer?: boolean;\n /** Freeze the loop without unmounting. @default false */\n paused?: boolean;\n /**\n * Frozen timestamp rendered as a single still frame for reduced-motion\n * users — pick one where the shader looks composed. @default 12\n */\n staticTime?: number;\n}\n\nconst VERT = `#version 300 es\nvoid main() {\n vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));\n gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}`;\n\nconst TYPES = [\"float\", \"vec2\", \"vec3\", \"vec4\"];\nconst arity = (v: UniformValue) => (typeof v === \"number\" ? 1 : v.length);\n\nfunction compile(gl: WebGL2RenderingContext, type: number, src: string) {\n const sh = gl.createShader(type);\n if (!sh) return null;\n gl.shaderSource(sh, src);\n gl.compileShader(sh);\n if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {\n // A lost context fails every compile with a null info log. That is not a\n // shader error and the restore handler will rebuild — don't cry wolf.\n if (process.env.NODE_ENV !== \"production\" && !gl.isContextLost()) {\n console.error(\"[ReactOmega] shader compile failed:\\n\" + gl.getShaderInfoLog(sh));\n }\n gl.deleteShader(sh);\n return null;\n }\n return sh;\n}\n\n/**\n * useShader — a dependency-free raw WebGL2 runtime for full-screen fragment shaders.\n *\n * Draws a single full-screen triangle with no vertex buffers (positions come from\n * `gl_VertexID`), so there is nothing to allocate and nothing to leak. It owns the\n * whole lifecycle: DPR-clamped sizing via ResizeObserver, an IntersectionObserver\n * that suspends the render loop while the canvas is off-screen, pausing on tab\n * blur, context-loss recovery, eased pointer input, and a single still frame for\n * reduced-motion users. Time does not jump after a pause — it accumulates only\n * while visible.\n *\n * Returns the canvas ref plus `supported`, which is `false` when WebGL2 is\n * unavailable so the component can fall back to CSS instead of a blank box.\n */\nexport function useShader({\n fragment,\n uniforms,\n speed = 1,\n dpr = 2,\n pointer = true,\n paused = false,\n staticTime = 12,\n}: UseShaderOptions) {\n const ref = useRef(null);\n const [supported, setSupported] = useState(true);\n const reduced = usePrefersReducedMotion();\n\n // Latest values, re-read each frame so prop changes never trigger a recompile.\n const live = useRef({ uniforms, speed, paused });\n live.current = { uniforms, speed, paused };\n\n // Uniform declarations are part of the program source, so they *are* a dep.\n const signature = uniforms ? Object.entries(uniforms).map(([k, v]) => `${k}:${arity(v)}`).join(\",\") : \"\";\n\n useEffect(() => {\n const canvas = ref.current;\n if (!canvas) return;\n\n const gl = canvas.getContext(\"webgl2\", {\n alpha: true,\n antialias: false,\n premultipliedAlpha: true,\n powerPreference: \"low-power\",\n failIfMajorPerformanceCaveat: false,\n });\n if (!gl) {\n setSupported(false);\n return;\n }\n setSupported(true);\n\n const decls = Object.entries(live.current.uniforms ?? {})\n .map(([k, v]) => `uniform ${TYPES[arity(v) - 1]} ${k};`)\n .join(\"\\n\");\n\n const frag = `#version 300 es\nprecision highp float;\nuniform vec2 uResolution;\nuniform float uTime;\nuniform vec3 uPointer;\n${decls}\nout vec4 fragColor;\n${fragment}`;\n\n let program: WebGLProgram | null = null;\n let locs: Record = {};\n\n const build = () => {\n if (gl.isContextLost()) return false; // the restore handler will retry\n const vs = compile(gl, gl.VERTEX_SHADER, VERT);\n const fs = compile(gl, gl.FRAGMENT_SHADER, frag);\n if (!vs || !fs) return false;\n const p = gl.createProgram();\n if (!p) return false;\n gl.attachShader(p, vs);\n gl.attachShader(p, fs);\n gl.linkProgram(p);\n gl.deleteShader(vs);\n gl.deleteShader(fs);\n if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {\n if (process.env.NODE_ENV !== \"production\" && !gl.isContextLost()) {\n console.error(\"[ReactOmega] shader link failed:\\n\" + gl.getProgramInfoLog(p));\n }\n gl.deleteProgram(p);\n return false;\n }\n program = p;\n locs = {};\n gl.useProgram(p);\n return true;\n };\n\n const onLost = (e: Event) => {\n e.preventDefault();\n program = null;\n };\n const onRestored = () => {\n if (build()) {\n w = h = 0;\n resizeRef.current?.();\n if (reduced) drawRef.current?.(staticTime);\n } else {\n setSupported(false);\n }\n };\n canvas.addEventListener(\"webglcontextlost\", onLost);\n canvas.addEventListener(\"webglcontextrestored\", onRestored);\n\n // Registered above so a context that is already down at mount can still come\n // back. Only a genuine compile/link failure marks the component unsupported.\n if (!build() && !gl.isContextLost()) {\n setSupported(false);\n canvas.removeEventListener(\"webglcontextlost\", onLost);\n canvas.removeEventListener(\"webglcontextrestored\", onRestored);\n return;\n }\n\n const loc = (name: string) => (name in locs ? locs[name] : (locs[name] = gl.getUniformLocation(program!, name)));\n\n // onRestored is registered before resize/draw are defined, so it reaches\n // them indirectly.\n const resizeRef: { current: (() => void) | null } = { current: null };\n const drawRef: { current: ((t: number) => void) | null } = { current: null };\n\n // ---- sizing -------------------------------------------------------------\n let w = 0;\n let h = 0;\n const ratio = () => Math.min(dpr, typeof window === \"undefined\" ? 1 : window.devicePixelRatio || 1);\n const resize = () => {\n const r = canvas.getBoundingClientRect();\n const px = ratio();\n const nw = Math.max(1, Math.round(r.width * px));\n const nh = Math.max(1, Math.round(r.height * px));\n if (nw === w && nh === h) return;\n w = canvas.width = nw;\n h = canvas.height = nh;\n gl.viewport(0, 0, w, h);\n };\n resizeRef.current = resize;\n resize();\n const ro = typeof ResizeObserver !== \"undefined\" ? new ResizeObserver(resize) : null;\n ro?.observe(canvas);\n\n // ---- pointer ------------------------------------------------------------\n const ptr = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, hover: 0, thover: 0 };\n const onMove = (e: PointerEvent) => {\n const r = canvas.getBoundingClientRect();\n if (!r.width || !r.height) return;\n ptr.tx = (e.clientX - r.left) / r.width;\n ptr.ty = 1 - (e.clientY - r.top) / r.height; // y-up, matches gl_FragCoord\n ptr.thover = 1;\n };\n const onLeave = () => {\n ptr.thover = 0;\n ptr.tx = 0.5;\n ptr.ty = 0.5;\n };\n if (pointer && !reduced) {\n canvas.addEventListener(\"pointermove\", onMove, { passive: true });\n canvas.addEventListener(\"pointerleave\", onLeave, { passive: true });\n }\n\n // ---- draw ---------------------------------------------------------------\n const draw = (t: number) => {\n if (!program) return;\n gl.useProgram(program);\n resize();\n gl.uniform2f(loc(\"uResolution\")!, w, h);\n gl.uniform1f(loc(\"uTime\")!, t);\n gl.uniform3f(loc(\"uPointer\")!, ptr.x * w, ptr.y * h, ptr.hover);\n for (const [k, v] of Object.entries(live.current.uniforms ?? {})) {\n const l = loc(k);\n if (!l) continue;\n if (typeof v === \"number\") gl.uniform1f(l, v);\n else if (v.length === 2) gl.uniform2f(l, v[0], v[1]);\n else if (v.length === 3) gl.uniform3f(l, v[0], v[1], v[2]);\n else gl.uniform4f(l, v[0], v[1], v[2], v[3]);\n }\n gl.drawArrays(gl.TRIANGLES, 0, 3);\n };\n\n drawRef.current = draw;\n\n // ---- loop ---------------------------------------------------------------\n let raf = 0;\n let clock = 0; // accumulates only while actually rendering\n let last = 0;\n let visible = true;\n\n if (reduced) {\n ptr.hover = 0;\n draw(staticTime);\n } else {\n const frame = (now: number) => {\n raf = requestAnimationFrame(frame);\n if (!visible || document.hidden || live.current.paused) {\n last = now;\n return;\n }\n // Clamp dt so returning to a backgrounded tab never fast-forwards.\n const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016);\n last = now;\n clock += dt * live.current.speed;\n ptr.x += (ptr.tx - ptr.x) * 0.12;\n ptr.y += (ptr.ty - ptr.y) * 0.12;\n ptr.hover += (ptr.thover - ptr.hover) * 0.08;\n draw(clock);\n };\n raf = requestAnimationFrame(frame);\n }\n\n // Suspend entirely while scrolled out of view — many shaders on one page\n // stay cheap because only the visible ones burn GPU.\n const io =\n typeof IntersectionObserver !== \"undefined\"\n ? new IntersectionObserver(\n (entries) => {\n visible = entries[0]?.isIntersecting ?? true;\n if (visible) last = 0;\n },\n { rootMargin: \"120px\" },\n )\n : null;\n io?.observe(canvas);\n\n return () => {\n cancelAnimationFrame(raf);\n ro?.disconnect();\n io?.disconnect();\n canvas.removeEventListener(\"pointermove\", onMove);\n canvas.removeEventListener(\"pointerleave\", onLeave);\n canvas.removeEventListener(\"webglcontextlost\", onLost);\n canvas.removeEventListener(\"webglcontextrestored\", onRestored);\n if (program) gl.deleteProgram(program);\n // Deliberately NOT calling WEBGL_lose_context here. A canvas returns the\n // same context object on every getContext call, so losing it would poison\n // every later mount on that canvas — React StrictMode re-runs effects in\n // dev, and any prop in the dependency list re-runs them in production.\n // The context is released when the canvas itself is collected.\n };\n }, [fragment, signature, speed, dpr, pointer, reduced, staticTime]);\n\n return { ref, supported };\n}\n\n/**\n * Parse `#rgb` / `#rrggbb` into a linear-ish 0..1 triple for shader uniforms.\n * Returns mid-grey for anything unparseable so a typo never blanks the canvas.\n */\nexport function hexToRgb(hex: string): [number, number, number] {\n let h = hex.trim().replace(/^#/, \"\");\n if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];\n if (!/^[0-9a-fA-F]{6}$/.test(h)) return [0.5, 0.5, 0.5];\n const n = parseInt(h, 16);\n return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n}\n" + } + ] +} diff --git a/public/r/volumetric-rays.json b/public/r/volumetric-rays.json new file mode 100644 index 0000000..b3898ed --- /dev/null +++ b/public/r/volumetric-rays.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "volumetric-rays", + "type": "registry:component", + "title": "Volumetric Rays", + "description": "God rays from a pointer-driven light source, integrated the classic way: march back toward the light sampling a drifting haze field and attenuating each step, with decay normalised by sample count so quality and brightness stay independent.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/volumetric-rays.tsx", + "type": "registry:component", + "target": "components/reactomega/volumetric-rays.tsx", + "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useShader, hexToRgb } from \"@/hooks/use-shader\";\n\nexport interface VolumetricRaysProps {\n className?: string;\n /** Colour of the light. @default \"#b9c6ff\" */\n color?: string;\n /** Samples along each ray — quality, not brightness. @default 34 */\n rays?: number;\n /** Falloff per unit distance along the ray, 0..1. @default 0.94 */\n decay?: number;\n /** Overall gain on the scattering integral. @default 0.42 */\n exposure?: number;\n /** Drift speed of the occluding haze. @default 1 */\n speed?: number;\n}\n\n/**\n * VolumetricRays — light shafts breaking through drifting haze, with the source\n * pinned to the pointer.\n *\n * This is the radial light-scattering integral, the same one used as a\n * post-process in real-time renderers. From each pixel the shader steps back\n * toward the light in a straight line; at every step it evaluates the light\n * that would arrive there — a bright falloff around the source, multiplied by\n * how much of it a two-octave drifting noise field lets past — and adds it in,\n * attenuated by a factor that compounds each step. Because every pixel marches\n * along its own line to the same origin, near-source brightness gets smeared\n * radially outward and the silhouette of the haze becomes the shaft. Decay is\n * raised to a per-step power derived from the sample count, so raising `rays`\n * refines the integral instead of dimming the frame.\n */\nexport function VolumetricRays({\n className,\n color = \"#b9c6ff\",\n rays = 34,\n decay = 0.94,\n exposure = 0.42,\n speed = 1,\n}: VolumetricRaysProps) {\n const uniforms = useMemo(\n () => ({\n uColor: hexToRgb(color),\n uRays: rays,\n uDecay: decay,\n uExposure: exposure,\n }),\n [color, rays, decay, exposure],\n );\n\n const { ref, supported } = useShader({\n speed,\n uniforms,\n fragment: FRAG,\n });\n\n if (!supported) {\n return (\n \n );\n }\n\n return ;\n}\n\nconst FRAG = /* glsl */ `\nfloat hash(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat vnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n}\n\n// The haze the light has to get through. Deliberately only two octaves — the\n// march itself integrates them into structure, so paying for more is wasted.\nfloat haze(vec2 p, float t) {\n float n = vnoise(p * 3.4 + vec2(t * 0.22, -t * 0.13));\n n += 0.5 * vnoise(p * 7.7 - vec2(t * 0.17, t * 0.28));\n // Hard-ish edges on purpose: a soft occluder integrates into fog, and what\n // makes a shaft legible is the sharp boundary between lit and blocked.\n return smoothstep(0.40, 0.64, n / 1.5);\n}\n\nvoid main() {\n float m = min(uResolution.x, uResolution.y);\n vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m;\n vec2 pc = (uPointer.xy - 0.5 * uResolution) / m;\n float t = uTime;\n\n // Light sits high and centred until the pointer takes it over.\n vec2 lp = mix(vec2(0.0, 0.30), pc, uPointer.z);\n\n float samples = clamp(uRays, 8.0, 64.0);\n vec2 delta = (uv - lp) / samples * 0.94;\n vec2 pos = uv;\n float illum = 1.0;\n float sum = 0.0;\n\n // Decay is per unit distance, not per step, so sample count is pure quality.\n float dec = pow(clamp(uDecay, 0.60, 0.999), 32.0 / samples);\n\n for (int i = 0; i < 64; i++) {\n if (float(i) >= samples) break;\n pos -= delta;\n float src = exp(-length(pos - lp) * 2.6);\n sum += src * (1.0 - haze(pos * 3.1, t)) * illum;\n illum *= dec;\n }\n sum = sum / samples * uExposure * 5.6; // mean along the ray, not the total\n\n float dl = length(uv - lp);\n vec3 col = uColor * 0.012;\n col += uColor * sum * exp(-dl * 0.75);\n col += uColor * 0.09 * exp(-dl * 2.4); // soft bloom\n col += uColor * 0.45 * exp(-dl * 8.0); // the source itself\n\n col = 1.0 - exp(-col * 1.15); // tone map — the source glows without clipping\n col *= 1.0 - 0.30 * dot(uv, uv);\n col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012;\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}\n`;\n" + } + ], + "meta": { + "category": "shader", + "tags": [ + "shader", + "webgl", + "light", + "rays", + "background" + ] + } +} diff --git a/registry.json b/registry.json index d575daa..b17e8bb 100644 --- a/registry.json +++ b/registry.json @@ -1,7 +1,7 @@ { "$schema": "https://ui.shadcn.com/schema/registry.json", "name": "reactomega", - "version": "1.0.0", + "version": "1.1.0", "homepage": "https://github.com/Edwson/ReactOmega", "base": "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r", "items": [ @@ -38,6 +38,23 @@ } ] }, + { + "name": "use-shader", + "type": "registry:hook", + "title": "useShader()", + "description": "A dependency-free raw WebGL2 runtime for full-screen fragment shaders: DPR-clamped sizing, suspension while off-screen, context-loss recovery, eased pointer input, auto-generated uniform declarations, and a single still frame for reduced-motion users.", + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "hooks/use-shader.ts", + "type": "registry:hook", + "target": "hooks/use-shader.ts" + } + ] + }, { "name": "split-text", "type": "registry:component", @@ -816,6 +833,523 @@ "target": "components/reactomega/falling-sand.tsx" } ] + }, + { + "name": "liquid-metal", + "type": "registry:component", + "title": "Liquid Metal", + "description": "A molten chrome surface that flows, catches light and dents away from the pointer. Two rounds of domain-warped FBM drive a height field; the normal is taken from finite differences each frame and lit with diffuse, specular and swept polish bands.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "metal", + "background", + "pointer" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/liquid-metal.tsx", + "type": "registry:component", + "target": "components/reactomega/liquid-metal.tsx" + } + ] + }, + { + "name": "thin-film", + "type": "registry:component", + "title": "Thin Film", + "description": "Iridescent soap-film sheen computed from real thin-film interference: phase is evaluated at three representative RGB wavelengths over a wobbling thickness field, so the fringe order is physical rather than a hue rotation. The pointer thins the film and shifts the bands.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "iridescent", + "interference", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/thin-film.tsx", + "type": "registry:component", + "target": "components/reactomega/thin-film.tsx" + } + ] + }, + { + "name": "caustics", + "type": "registry:component", + "title": "Caustics", + "description": "Underwater light caustics: a coordinate folded back on itself several times, each pass accumulating reciprocal distance, then gamma-crushed so only the focus filaments survive. The pointer disturbs the water above.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "water", + "caustics", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/caustics.tsx", + "type": "registry:component", + "target": "components/reactomega/caustics.tsx" + } + ] + }, + { + "name": "halftone-gradient", + "type": "registry:component", + "title": "Halftone Gradient", + "description": "A drifting multi-stop gradient rendered through a print screen — either angled per-channel dots with radius proportional to the square root of tone, or a computed 4x4 ordered Bayer matrix. The screen opens up under the pointer.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "halftone", + "dither", + "print", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/halftone-gradient.tsx", + "type": "registry:component", + "target": "components/reactomega/halftone-gradient.tsx" + } + ] + }, + { + "name": "volumetric-rays", + "type": "registry:component", + "title": "Volumetric Rays", + "description": "God rays from a pointer-driven light source, integrated the classic way: march back toward the light sampling a drifting haze field and attenuating each step, with decay normalised by sample count so quality and brightness stay independent.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "light", + "rays", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/volumetric-rays.tsx", + "type": "registry:component", + "target": "components/reactomega/volumetric-rays.tsx" + } + ] + }, + { + "name": "curl-flow", + "type": "registry:component", + "title": "Curl Flow", + "description": "Silk-like flowing bands from a curl-noise field. FBM acts as a stream function and the velocity is the perpendicular of its gradient, so the field is divergence-free by construction; coordinates advect along it and shade as anisotropic streamlines. The pointer injects a vortex.", + "category": "shader", + "tags": [ + "shader", + "webgl", + "curl-noise", + "flow", + "silk", + "background" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-shader.json" + ], + "files": [ + { + "path": "components/reactomega/curl-flow.tsx", + "type": "registry:component", + "target": "components/reactomega/curl-flow.tsx" + } + ] + }, + { + "name": "inertia-cursor", + "type": "registry:component", + "title": "Inertia Cursor", + "description": "Replaces the native cursor with an exact dot plus a ring that lags on a spring, growing over anything matching a selector. Hover is delegated from the document so late-mounted elements work, and difference blending keeps it visible on any background.", + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "spring", + "blend" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/inertia-cursor.tsx", + "type": "registry:component", + "target": "components/reactomega/inertia-cursor.tsx" + } + ] + }, + { + "name": "elastic-cursor", + "type": "registry:component", + "title": "Elastic Cursor", + "description": "A gooey cursor that stretches along its own velocity vector and squashes perpendicular so its area is conserved exactly, rotating into the direction of travel and settling back to a circle at rest. Optional trailing blobs are welded together by an SVG goo filter.", + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "goo", + "elastic", + "svg" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/elastic-cursor.tsx", + "type": "registry:component", + "target": "components/reactomega/elastic-cursor.tsx" + } + ] + }, + { + "name": "image-trail", + "type": "registry:component", + "title": "Image Trail", + "description": "Drops images along the pointer's path as it crosses a container, each fading and scaling out. Emission is thresholded by distance travelled rather than by event, with the remainder carried between events, so spacing stays even at any speed.", + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "trail", + "images", + "hover" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/image-trail.tsx", + "type": "registry:component", + "target": "components/reactomega/image-trail.tsx" + } + ] + }, + { + "name": "pixel-trail", + "type": "registry:component", + "title": "Pixel Trail", + "description": "A canvas grid whose cells ignite under the pointer and decay exponentially into a dissolving comet trail. The path between the previous and current position is walked at sub-cell steps, so a fast flick still lights a continuous line.", + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "canvas", + "pixels", + "trail" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/pixel-trail.tsx", + "type": "registry:component", + "target": "components/reactomega/pixel-trail.tsx" + } + ] + }, + { + "name": "crosshair", + "type": "registry:component", + "title": "Crosshair", + "description": "A precision overlay: full-bleed hairlines, a centre reticle and a live monospace coordinate readout that snap to nearby targets. Distance is measured point-to-rectangle so hovering a target always locks, and one smoothstepped scalar drives the whole engage.", + "category": "cursor", + "tags": [ + "cursor", + "pointer", + "crosshair", + "snapping", + "instrument" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/crosshair.tsx", + "type": "registry:component", + "target": "components/reactomega/crosshair.tsx" + } + ] + }, + { + "name": "scroll-stack", + "type": "registry:component", + "title": "Scroll Stack", + "description": "Cards that stack into a deck as you scroll, pinned with native sticky positioning. Each card's depth is the running sum of how far the later cards have travelled, so the scale, dim and blur are continuous rather than stepped, and the front card always stays clean.", + "category": "scroll", + "tags": [ + "scroll", + "sticky", + "cards", + "stack" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/scroll-stack.tsx", + "type": "registry:component", + "target": "components/reactomega/scroll-stack.tsx" + } + ] + }, + { + "name": "scroll-velocity", + "type": "registry:component", + "title": "Scroll Velocity", + "description": "A seamless row that drifts at a base speed but is driven by scroll velocity, skewing as it lurches and easing back to baseline when you stop. Velocity is smoothed with a time-constant decay, so the feel is identical at 60Hz and 120Hz.", + "category": "scroll", + "tags": [ + "scroll", + "velocity", + "marquee", + "skew" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/scroll-velocity.tsx", + "type": "registry:component", + "target": "components/reactomega/scroll-velocity.tsx" + } + ] + }, + { + "name": "parallax-layers", + "type": "registry:component", + "title": "Parallax Layers", + "description": "Depth layers that drift at different rates as a section crosses the viewport, with optional scale and blur on distant layers. Progress is normalised against the section's own centre, so the composition is neutral exactly when the section is centred.", + "category": "scroll", + "tags": [ + "scroll", + "parallax", + "depth", + "layers" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/parallax-layers.tsx", + "type": "registry:component", + "target": "components/reactomega/parallax-layers.tsx" + } + ] + }, + { + "name": "scroll-scene", + "type": "registry:component", + "title": "Scroll Scene", + "description": "Pins a section and exposes its scrub progress from 0 to 1 through a render prop, plus a live CSS custom property and a per-frame callback. Travel is derived from the measured spacer and pinned heights, so there is no viewport constant to get wrong.", + "category": "scroll", + "tags": [ + "scroll", + "pin", + "scrub", + "progress", + "render-prop" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/scroll-scene.tsx", + "type": "registry:component", + "target": "components/reactomega/scroll-scene.tsx" + } + ] + }, + { + "name": "card-swap", + "type": "registry:component", + "title": "Card Swap", + "description": "A 3D deck where the front card peels away and the stack advances on an interval or on click. Every slot is a pure function of a card's distance from the front, so CSS transitions do all the interpolation; it pauses on hover and focus and announces changes politely.", + "category": "components", + "tags": [ + "components", + "cards", + "3d", + "carousel" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/card-swap.tsx", + "type": "registry:component", + "target": "components/reactomega/card-swap.tsx" + } + ] + }, + { + "name": "gooey-nav", + "type": "registry:component", + "title": "Gooey Nav", + "description": "A pill navigation whose liquid indicator stretches as it travels between items and settles into a single pill, fused by an SVG goo filter. Real buttons with roving tabindex and arrow-key movement; labels stay outside the filter so text remains crisp.", + "category": "components", + "tags": [ + "components", + "nav", + "goo", + "svg", + "tabs" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/gooey-nav.tsx", + "type": "registry:component", + "target": "components/reactomega/gooey-nav.tsx" + } + ] + }, + { + "name": "bento-grid", + "type": "registry:component", + "title": "Bento Grid", + "description": "A bento layout where the grid owns the only pointer listener and publishes shared coordinates as inherited custom properties, so the spotlight is pure CSS and continuous across gutters no matter how many tiles there are. Tiles add proximity glow and a subtle tilt.", + "category": "components", + "tags": [ + "components", + "bento", + "grid", + "spotlight", + "tilt" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/bento-grid.tsx", + "type": "registry:component", + "target": "components/reactomega/bento-grid.tsx" + } + ] + }, + { + "name": "elastic-slider", + "type": "registry:component", + "title": "Elastic Slider", + "description": "A slider whose rail stretches like rubber past either end with exponentially diminishing overshoot, then springs back on release. Implements the full slider ARIA contract with arrow, page, home and end keys, pointer capture and touch support.", + "category": "components", + "tags": [ + "components", + "slider", + "input", + "elastic", + "a11y" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/elastic-slider.tsx", + "type": "registry:component", + "target": "components/reactomega/elastic-slider.tsx" + } + ] + }, + { + "name": "circular-gallery", + "type": "registry:component", + "title": "Circular Gallery", + "description": "Items arranged around a horizontal cylinder that you drag to spin, with inertia on release and optional snapping to the nearest item. Drag is converted through arc length so tracking is one-to-one at any radius, and items past the horizon dim and stop taking pointer events.", + "category": "components", + "tags": [ + "components", + "gallery", + "carousel", + "3d", + "drag" + ], + "dependencies": [], + "registryDependencies": [ + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/utils.json", + "https://cdn.jsdelivr.net/gh/Edwson/ReactOmega@main/public/r/use-prefers-reduced-motion.json" + ], + "files": [ + { + "path": "components/reactomega/circular-gallery.tsx", + "type": "registry:component", + "target": "components/reactomega/circular-gallery.tsx" + } + ] } ] } diff --git a/registry/meta.json b/registry/meta.json index f418878..1bc4ab2 100644 --- a/registry/meta.json +++ b/registry/meta.json @@ -1,6 +1,6 @@ { "$comment": "Single source of truth for the ReactOmega registry. scripts/build-registry.mjs reads this + the component source files to generate registry.json, public/r/*.json (shadcn-compatible), and llms.txt. 'uses' lists internal registry dependencies (primitives). 'dependencies' lists npm packages a consumer must install.", - "version": "1.0.0", + "version": "1.1.0", "primitives": { "utils": { "title": "cn() utility", @@ -8,7 +8,10 @@ "description": "Tailwind class merge helper used by every component.", "source": "registry/reactomega/lib/utils.ts", "target": "lib/utils.ts", - "dependencies": ["clsx", "tailwind-merge"], + "dependencies": [ + "clsx", + "tailwind-merge" + ], "uses": [] }, "use-prefers-reduced-motion": { @@ -19,6 +22,17 @@ "target": "hooks/use-prefers-reduced-motion.ts", "dependencies": [], "uses": [] + }, + "use-shader": { + "title": "useShader()", + "type": "registry:hook", + "description": "A dependency-free raw WebGL2 runtime for full-screen fragment shaders: DPR-clamped sizing, suspension while off-screen, context-loss recovery, eased pointer input, auto-generated uniform declarations, and a single still frame for reduced-motion users.", + "source": "registry/reactomega/hooks/use-shader.ts", + "target": "hooks/use-shader.ts", + "dependencies": [], + "uses": [ + "use-prefers-reduced-motion" + ] } }, "components": { @@ -26,249 +40,838 @@ "title": "Split Text", "category": "text", "description": "Reveals text per character or word on scroll into view with a staggered spring rise. Screen-reader safe via aria-label.", - "tags": ["text", "reveal", "scroll", "stagger"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "text", + "reveal", + "scroll", + "stagger" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "shiny-text": { "title": "Shiny Text", "category": "text", "description": "A soft highlight sweeps across text on a loop. Pure CSS, zero dependencies.", - "tags": ["text", "shine", "css"], - "dependencies": [], - "uses": ["utils"] + "tags": [ + "text", + "shine", + "css" + ], + "dependencies": [], + "uses": [ + "utils" + ] }, "gradient-text": { "title": "Gradient Text", "category": "text", "description": "An animated multi-stop gradient flows through the text. Pure CSS.", - "tags": ["text", "gradient", "css"], - "dependencies": [], - "uses": ["utils"] + "tags": [ + "text", + "gradient", + "css" + ], + "dependencies": [], + "uses": [ + "utils" + ] }, "count-up": { "title": "Count Up", "category": "text", "description": "Rolls a number from a start to a target with ease-out, optionally when scrolled into view. tabular-nums prevents width jitter.", - "tags": ["text", "number", "counter", "scroll"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "text", + "number", + "counter", + "scroll" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "magnetic": { "title": "Magnetic", "category": "interaction", "description": "Wraps any element so it drifts toward the pointer and springs back on leave. Mouse-only, reduced-motion safe.", - "tags": ["interaction", "pointer", "magnetic", "hover"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "interaction", + "pointer", + "magnetic", + "hover" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "spotlight-card": { "title": "Spotlight Card", "category": "interaction", "description": "A card with a soft radial spotlight that tracks the pointer and fades on enter/leave.", - "tags": ["interaction", "card", "spotlight", "pointer"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "interaction", + "card", + "spotlight", + "pointer" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "tilt-card": { "title": "Tilt Card", "category": "interaction", "description": "A 3D card that tilts toward the pointer with an optional moving glare. Flat and static for reduced-motion users.", - "tags": ["interaction", "card", "3d", "tilt", "glare"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "interaction", + "card", + "3d", + "tilt", + "glare" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "click-spark": { "title": "Click Spark", "category": "interaction", "description": "Emits a radial spark burst from the exact click point on a self-clearing canvas overlay.", - "tags": ["interaction", "click", "spark", "canvas"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "interaction", + "click", + "spark", + "canvas" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "dot-grid": { "title": "Dot Grid", "category": "interaction", "description": "An interactive dot field that lights up and swells around the pointer. A modern, non-cosmic backdrop.", - "tags": ["interaction", "background", "canvas", "pointer"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "interaction", + "background", + "canvas", + "pointer" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "star-border": { "title": "Star Border", "category": "components", "description": "Two soft glints travel continuously around the border of any element. Pure CSS.", - "tags": ["components", "button", "border", "css"], - "dependencies": [], - "uses": ["utils"] + "tags": [ + "components", + "button", + "border", + "css" + ], + "dependencies": [], + "uses": [ + "utils" + ] }, "dock": { "title": "Dock", "category": "components", "description": "A macOS-style magnifying dock; items swell based on pointer distance. Renders as a toolbar.", - "tags": ["components", "dock", "magnify", "nav"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "components", + "dock", + "magnify", + "nav" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "marquee": { "title": "Marquee", "category": "components", "description": "A seamless infinite scroller (horizontal or vertical) with edge fade and pause-on-hover.", - "tags": ["components", "marquee", "scroll", "css"], - "dependencies": [], - "uses": ["utils"] + "tags": [ + "components", + "marquee", + "scroll", + "css" + ], + "dependencies": [], + "uses": [ + "utils" + ] }, "variable-proximity": { "title": "Variable Proximity", "category": "text", "description": "Every letter's variable-font axes (weight, width, optical size, slant, or any custom axis) interpolate based on pointer distance. Fully configurable axes, radius, and falloff curve. Needs a variable font.", - "tags": ["text", "variable-font", "proximity", "pointer", "customizable"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "text", + "variable-font", + "proximity", + "pointer", + "customizable" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "magnetic-text": { "title": "Magnetic Text", "category": "text", "description": "Every letter is independently magnetic, springing toward the pointer and easing back with real spring physics. Configurable strength, radius, stiffness, damping.", - "tags": ["text", "magnetic", "spring", "pointer", "physics"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "text", + "magnetic", + "spring", + "pointer", + "physics" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "gravity-text": { "title": "Gravity Text", "category": "text", "description": "Letters detach and fall under real gravity, bounce off the floor with friction, then reassemble. Configurable gravity, bounce, friction, scatter, and trigger.", - "tags": ["text", "gravity", "physics", "bounce", "interactive"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "text", + "gravity", + "physics", + "bounce", + "interactive" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "elastic-text": { "title": "Elastic Text", "category": "text", "description": "Letters swell and lift with a springy, overshooting jelly squash-and-stretch as the pointer passes. Configurable stiffness, damping, scale, lift, radius.", - "tags": ["text", "elastic", "jelly", "spring", "physics"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "text", + "elastic", + "jelly", + "spring", + "physics" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "scramble-text": { "title": "Scramble Text", "category": "text", "description": "Letters churn through random glyphs and lock in left-to-right like a decoding terminal. Configurable charset, speed, reveal wipe, and trigger.", - "tags": ["text", "scramble", "decode", "terminal", "customizable"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "text", + "scramble", + "decode", + "terminal", + "customizable" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "spring-mesh": { "title": "Spring Mesh", "category": "physics", "description": "An elastic lattice: every node is sprung to rest and coupled to its neighbors, so pressing the pointer sends real waves rippling outward that settle naturally.", - "tags": ["physics", "spring", "mesh", "canvas", "interactive"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "spring", + "mesh", + "canvas", + "interactive" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "cloth": { "title": "Cloth", "category": "physics", "description": "A real verlet-integrated fabric hanging from a pinned top edge under gravity. Grab any point and it folds, swings, and settles like cloth.", - "tags": ["physics", "verlet", "cloth", "drag", "canvas"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "verlet", + "cloth", + "drag", + "canvas" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "metaballs": { "title": "Metaballs", "category": "physics", "description": "A gooey fluid that flows toward the pointer and merges with surface-tension feel via an SVG goo filter. Configurable count, color, size, and viscosity.", - "tags": ["physics", "fluid", "gooey", "metaballs", "pointer"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "fluid", + "gooey", + "metaballs", + "pointer" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "holographic-card": { "title": "Holographic Card", "category": "interaction", "description": "An iridescent holo-foil card: a rainbow sheen and bright glare track the pointer with a color-dodge blend, over a subtle 3D tilt. Configurable tilt, intensity, and foil colors.", - "tags": ["interaction", "card", "holographic", "foil", "3d"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "interaction", + "card", + "holographic", + "foil", + "3d" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "text-reveal": { "title": "Text Reveal", "category": "text", - "description": "Words brighten one after another as the block scrolls through the viewport — a scroll-linked reading spotlight. Configurable dim/lit colors and edge softness.", - "tags": ["text", "scroll", "reveal", "reading"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "description": "Words brighten one after another as the block scrolls through the viewport \u2014 a scroll-linked reading spotlight. Configurable dim/lit colors and edge softness.", + "tags": [ + "text", + "scroll", + "reveal", + "reading" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "rope": { "title": "Rope", "category": "physics", - "description": "A verlet-integrated rope hangs from a fixed anchor — grab it, drag it, and watch it swing and settle under gravity.", - "tags": ["physics", "verlet", "canvas", "drag"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "description": "A verlet-integrated rope hangs from a fixed anchor \u2014 grab it, drag it, and watch it swing and settle under gravity.", + "tags": [ + "physics", + "verlet", + "canvas", + "drag" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "pendulum-wave": { "title": "Pendulum Wave", "category": "physics", "description": "A row of pendulums with subtly increasing lengths drift in and out of phase to form the classic traveling pendulum-wave pattern.", - "tags": ["physics", "pendulum", "wave", "canvas"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "pendulum", + "wave", + "canvas" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "newtons-cradle": { "title": "Newton's Cradle", "category": "physics", - "description": "An interactive Newton's cradle — pull an end ball back, release, and momentum transfers through the row to fling the far ball out.", - "tags": ["physics", "collision", "momentum", "canvas"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "description": "An interactive Newton's cradle \u2014 pull an end ball back, release, and momentum transfers through the row to fling the far ball out.", + "tags": [ + "physics", + "collision", + "momentum", + "canvas" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "water-ripple": { "title": "Water Ripple", "category": "physics", "description": "A height-field water surface where pointer input spawns ripples that spread, reflect off edges, and shade from the local height gradient.", - "tags": ["physics", "water", "ripple", "canvas"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "water", + "ripple", + "canvas" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "gravity-wells": { "title": "Gravity Wells", "category": "physics", "description": "Hundreds of particles orbit the pointer as a 1/dist^2 gravity well, leaving luminous trails via per-frame translucent fade.", - "tags": ["physics", "particles", "gravity", "trails"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "particles", + "gravity", + "trails" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "magnetic-field": { "title": "Magnetic Field", "category": "physics", "description": "A grid of iron-filing segments that re-orient along a dipole field anchored at the pointer, with length and brightness scaling by proximity.", - "tags": ["physics", "magnetism", "field", "filings"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "magnetism", + "field", + "filings" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "soft-body": { "title": "Soft Body", "category": "physics", "description": "A poke-able jelly blob held in a circle by perimeter, radius, and pressure springs that wobbles and recovers when you squish it with the pointer.", - "tags": ["physics", "soft-body", "springs", "canvas"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "soft-body", + "springs", + "canvas" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "plucked-string": { "title": "Plucked String", "category": "physics", "description": "A string pinned at both ends that you grab and pluck, then watch vibrate with a realistic decaying wave driven by neighbor-averaging physics.", - "tags": ["physics", "string", "wave", "canvas"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "string", + "wave", + "canvas" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] }, "falling-sand": { "title": "Falling Sand", "category": "physics", "description": "A falling-sand cellular automaton where dragging the pointer pours colored grains that fall and slide diagonally into realistic piling mounds.", - "tags": ["physics", "cellular-automaton", "sand", "canvas"], - "dependencies": [], - "uses": ["utils", "use-prefers-reduced-motion"] + "tags": [ + "physics", + "cellular-automaton", + "sand", + "canvas" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "liquid-metal": { + "title": "Liquid Metal", + "category": "shader", + "description": "A molten chrome surface that flows, catches light and dents away from the pointer. Two rounds of domain-warped FBM drive a height field; the normal is taken from finite differences each frame and lit with diffuse, specular and swept polish bands.", + "tags": [ + "shader", + "webgl", + "metal", + "background", + "pointer" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "thin-film": { + "title": "Thin Film", + "category": "shader", + "description": "Iridescent soap-film sheen computed from real thin-film interference: phase is evaluated at three representative RGB wavelengths over a wobbling thickness field, so the fringe order is physical rather than a hue rotation. The pointer thins the film and shifts the bands.", + "tags": [ + "shader", + "webgl", + "iridescent", + "interference", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "caustics": { + "title": "Caustics", + "category": "shader", + "description": "Underwater light caustics: a coordinate folded back on itself several times, each pass accumulating reciprocal distance, then gamma-crushed so only the focus filaments survive. The pointer disturbs the water above.", + "tags": [ + "shader", + "webgl", + "water", + "caustics", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "halftone-gradient": { + "title": "Halftone Gradient", + "category": "shader", + "description": "A drifting multi-stop gradient rendered through a print screen \u2014 either angled per-channel dots with radius proportional to the square root of tone, or a computed 4x4 ordered Bayer matrix. The screen opens up under the pointer.", + "tags": [ + "shader", + "webgl", + "halftone", + "dither", + "print", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "volumetric-rays": { + "title": "Volumetric Rays", + "category": "shader", + "description": "God rays from a pointer-driven light source, integrated the classic way: march back toward the light sampling a drifting haze field and attenuating each step, with decay normalised by sample count so quality and brightness stay independent.", + "tags": [ + "shader", + "webgl", + "light", + "rays", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "curl-flow": { + "title": "Curl Flow", + "category": "shader", + "description": "Silk-like flowing bands from a curl-noise field. FBM acts as a stream function and the velocity is the perpendicular of its gradient, so the field is divergence-free by construction; coordinates advect along it and shade as anisotropic streamlines. The pointer injects a vortex.", + "tags": [ + "shader", + "webgl", + "curl-noise", + "flow", + "silk", + "background" + ], + "dependencies": [], + "uses": [ + "utils", + "use-shader" + ] + }, + "inertia-cursor": { + "title": "Inertia Cursor", + "category": "cursor", + "description": "Replaces the native cursor with an exact dot plus a ring that lags on a spring, growing over anything matching a selector. Hover is delegated from the document so late-mounted elements work, and difference blending keeps it visible on any background.", + "tags": [ + "cursor", + "pointer", + "spring", + "blend" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "elastic-cursor": { + "title": "Elastic Cursor", + "category": "cursor", + "description": "A gooey cursor that stretches along its own velocity vector and squashes perpendicular so its area is conserved exactly, rotating into the direction of travel and settling back to a circle at rest. Optional trailing blobs are welded together by an SVG goo filter.", + "tags": [ + "cursor", + "pointer", + "goo", + "elastic", + "svg" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "image-trail": { + "title": "Image Trail", + "category": "cursor", + "description": "Drops images along the pointer's path as it crosses a container, each fading and scaling out. Emission is thresholded by distance travelled rather than by event, with the remainder carried between events, so spacing stays even at any speed.", + "tags": [ + "cursor", + "pointer", + "trail", + "images", + "hover" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "pixel-trail": { + "title": "Pixel Trail", + "category": "cursor", + "description": "A canvas grid whose cells ignite under the pointer and decay exponentially into a dissolving comet trail. The path between the previous and current position is walked at sub-cell steps, so a fast flick still lights a continuous line.", + "tags": [ + "cursor", + "pointer", + "canvas", + "pixels", + "trail" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "crosshair": { + "title": "Crosshair", + "category": "cursor", + "description": "A precision overlay: full-bleed hairlines, a centre reticle and a live monospace coordinate readout that snap to nearby targets. Distance is measured point-to-rectangle so hovering a target always locks, and one smoothstepped scalar drives the whole engage.", + "tags": [ + "cursor", + "pointer", + "crosshair", + "snapping", + "instrument" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "scroll-stack": { + "title": "Scroll Stack", + "category": "scroll", + "description": "Cards that stack into a deck as you scroll, pinned with native sticky positioning. Each card's depth is the running sum of how far the later cards have travelled, so the scale, dim and blur are continuous rather than stepped, and the front card always stays clean.", + "tags": [ + "scroll", + "sticky", + "cards", + "stack" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "scroll-velocity": { + "title": "Scroll Velocity", + "category": "scroll", + "description": "A seamless row that drifts at a base speed but is driven by scroll velocity, skewing as it lurches and easing back to baseline when you stop. Velocity is smoothed with a time-constant decay, so the feel is identical at 60Hz and 120Hz.", + "tags": [ + "scroll", + "velocity", + "marquee", + "skew" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "parallax-layers": { + "title": "Parallax Layers", + "category": "scroll", + "description": "Depth layers that drift at different rates as a section crosses the viewport, with optional scale and blur on distant layers. Progress is normalised against the section's own centre, so the composition is neutral exactly when the section is centred.", + "tags": [ + "scroll", + "parallax", + "depth", + "layers" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "scroll-scene": { + "title": "Scroll Scene", + "category": "scroll", + "description": "Pins a section and exposes its scrub progress from 0 to 1 through a render prop, plus a live CSS custom property and a per-frame callback. Travel is derived from the measured spacer and pinned heights, so there is no viewport constant to get wrong.", + "tags": [ + "scroll", + "pin", + "scrub", + "progress", + "render-prop" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "card-swap": { + "title": "Card Swap", + "category": "components", + "description": "A 3D deck where the front card peels away and the stack advances on an interval or on click. Every slot is a pure function of a card's distance from the front, so CSS transitions do all the interpolation; it pauses on hover and focus and announces changes politely.", + "tags": [ + "components", + "cards", + "3d", + "carousel" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "gooey-nav": { + "title": "Gooey Nav", + "category": "components", + "description": "A pill navigation whose liquid indicator stretches as it travels between items and settles into a single pill, fused by an SVG goo filter. Real buttons with roving tabindex and arrow-key movement; labels stay outside the filter so text remains crisp.", + "tags": [ + "components", + "nav", + "goo", + "svg", + "tabs" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "bento-grid": { + "title": "Bento Grid", + "category": "components", + "description": "A bento layout where the grid owns the only pointer listener and publishes shared coordinates as inherited custom properties, so the spotlight is pure CSS and continuous across gutters no matter how many tiles there are. Tiles add proximity glow and a subtle tilt.", + "tags": [ + "components", + "bento", + "grid", + "spotlight", + "tilt" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "elastic-slider": { + "title": "Elastic Slider", + "category": "components", + "description": "A slider whose rail stretches like rubber past either end with exponentially diminishing overshoot, then springs back on release. Implements the full slider ARIA contract with arrow, page, home and end keys, pointer capture and touch support.", + "tags": [ + "components", + "slider", + "input", + "elastic", + "a11y" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] + }, + "circular-gallery": { + "title": "Circular Gallery", + "category": "components", + "description": "Items arranged around a horizontal cylinder that you drag to spin, with inertia on release and optional snapping to the nearest item. Drag is converted through arc length so tracking is one-to-one at any radius, and items past the horizon dim and stop taking pointer events.", + "tags": [ + "components", + "gallery", + "carousel", + "3d", + "drag" + ], + "dependencies": [], + "uses": [ + "utils", + "use-prefers-reduced-motion" + ] } } } diff --git a/registry/reactomega/hooks/use-shader.ts b/registry/reactomega/hooks/use-shader.ts new file mode 100644 index 0000000..bde1597 --- /dev/null +++ b/registry/reactomega/hooks/use-shader.ts @@ -0,0 +1,323 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +/** A uniform value: float, vec2, vec3 or vec4. */ +export type UniformValue = number | readonly [number, number] | readonly [number, number, number] | readonly [number, number, number, number]; + +export interface UseShaderOptions { + /** + * GLSL ES 3.00 fragment shader body. The hook prepends `#version 300 es`, + * a highp precision qualifier, the built-in uniforms and `out vec4 fragColor` + * — so your source starts at your own helpers / `void main()`. + * + * Built-ins available to every shader: + * uniform vec2 uResolution; // drawing-buffer size in device pixels + * uniform float uTime; // seconds since mount, scaled by `speed` + * uniform vec3 uPointer; // xy = pointer in device px (y up), z = 0..1 eased hover + */ + fragment: string; + /** + * Custom uniforms. Declarations are generated from the arity of each value, + * so `{ uTint: [0.48, 0.36, 1] }` emits `uniform vec3 uTint;` automatically. + * Values are re-read every frame — changing a prop never recompiles. + */ + uniforms?: Record; + /** Time multiplier. @default 1 */ + speed?: number; + /** Device-pixel-ratio ceiling. Caps GPU cost on retina displays. @default 2 */ + dpr?: number; + /** Track the pointer and feed `uPointer`. @default true */ + pointer?: boolean; + /** Freeze the loop without unmounting. @default false */ + paused?: boolean; + /** + * Frozen timestamp rendered as a single still frame for reduced-motion + * users — pick one where the shader looks composed. @default 12 + */ + staticTime?: number; +} + +const VERT = `#version 300 es +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +}`; + +const TYPES = ["float", "vec2", "vec3", "vec4"]; +const arity = (v: UniformValue) => (typeof v === "number" ? 1 : v.length); + +function compile(gl: WebGL2RenderingContext, type: number, src: string) { + const sh = gl.createShader(type); + if (!sh) return null; + gl.shaderSource(sh, src); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + // A lost context fails every compile with a null info log. That is not a + // shader error and the restore handler will rebuild — don't cry wolf. + if (process.env.NODE_ENV !== "production" && !gl.isContextLost()) { + console.error("[ReactOmega] shader compile failed:\n" + gl.getShaderInfoLog(sh)); + } + gl.deleteShader(sh); + return null; + } + return sh; +} + +/** + * useShader — a dependency-free raw WebGL2 runtime for full-screen fragment shaders. + * + * Draws a single full-screen triangle with no vertex buffers (positions come from + * `gl_VertexID`), so there is nothing to allocate and nothing to leak. It owns the + * whole lifecycle: DPR-clamped sizing via ResizeObserver, an IntersectionObserver + * that suspends the render loop while the canvas is off-screen, pausing on tab + * blur, context-loss recovery, eased pointer input, and a single still frame for + * reduced-motion users. Time does not jump after a pause — it accumulates only + * while visible. + * + * Returns the canvas ref plus `supported`, which is `false` when WebGL2 is + * unavailable so the component can fall back to CSS instead of a blank box. + */ +export function useShader({ + fragment, + uniforms, + speed = 1, + dpr = 2, + pointer = true, + paused = false, + staticTime = 12, +}: UseShaderOptions) { + const ref = useRef(null); + const [supported, setSupported] = useState(true); + const reduced = usePrefersReducedMotion(); + + // Latest values, re-read each frame so prop changes never trigger a recompile. + const live = useRef({ uniforms, speed, paused }); + live.current = { uniforms, speed, paused }; + + // Uniform declarations are part of the program source, so they *are* a dep. + const signature = uniforms ? Object.entries(uniforms).map(([k, v]) => `${k}:${arity(v)}`).join(",") : ""; + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + + const gl = canvas.getContext("webgl2", { + alpha: true, + antialias: false, + premultipliedAlpha: true, + powerPreference: "low-power", + failIfMajorPerformanceCaveat: false, + }); + if (!gl) { + setSupported(false); + return; + } + setSupported(true); + + const decls = Object.entries(live.current.uniforms ?? {}) + .map(([k, v]) => `uniform ${TYPES[arity(v) - 1]} ${k};`) + .join("\n"); + + const frag = `#version 300 es +precision highp float; +uniform vec2 uResolution; +uniform float uTime; +uniform vec3 uPointer; +${decls} +out vec4 fragColor; +${fragment}`; + + let program: WebGLProgram | null = null; + let locs: Record = {}; + + const build = () => { + if (gl.isContextLost()) return false; // the restore handler will retry + const vs = compile(gl, gl.VERTEX_SHADER, VERT); + const fs = compile(gl, gl.FRAGMENT_SHADER, frag); + if (!vs || !fs) return false; + const p = gl.createProgram(); + if (!p) return false; + gl.attachShader(p, vs); + gl.attachShader(p, fs); + gl.linkProgram(p); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { + if (process.env.NODE_ENV !== "production" && !gl.isContextLost()) { + console.error("[ReactOmega] shader link failed:\n" + gl.getProgramInfoLog(p)); + } + gl.deleteProgram(p); + return false; + } + program = p; + locs = {}; + gl.useProgram(p); + return true; + }; + + const onLost = (e: Event) => { + e.preventDefault(); + program = null; + }; + const onRestored = () => { + if (build()) { + w = h = 0; + resizeRef.current?.(); + if (reduced) drawRef.current?.(staticTime); + } else { + setSupported(false); + } + }; + canvas.addEventListener("webglcontextlost", onLost); + canvas.addEventListener("webglcontextrestored", onRestored); + + // Registered above so a context that is already down at mount can still come + // back. Only a genuine compile/link failure marks the component unsupported. + if (!build() && !gl.isContextLost()) { + setSupported(false); + canvas.removeEventListener("webglcontextlost", onLost); + canvas.removeEventListener("webglcontextrestored", onRestored); + return; + } + + const loc = (name: string) => (name in locs ? locs[name] : (locs[name] = gl.getUniformLocation(program!, name))); + + // onRestored is registered before resize/draw are defined, so it reaches + // them indirectly. + const resizeRef: { current: (() => void) | null } = { current: null }; + const drawRef: { current: ((t: number) => void) | null } = { current: null }; + + // ---- sizing ------------------------------------------------------------- + let w = 0; + let h = 0; + const ratio = () => Math.min(dpr, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1); + const resize = () => { + const r = canvas.getBoundingClientRect(); + const px = ratio(); + const nw = Math.max(1, Math.round(r.width * px)); + const nh = Math.max(1, Math.round(r.height * px)); + if (nw === w && nh === h) return; + w = canvas.width = nw; + h = canvas.height = nh; + gl.viewport(0, 0, w, h); + }; + resizeRef.current = resize; + resize(); + const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null; + ro?.observe(canvas); + + // ---- pointer ------------------------------------------------------------ + const ptr = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, hover: 0, thover: 0 }; + const onMove = (e: PointerEvent) => { + const r = canvas.getBoundingClientRect(); + if (!r.width || !r.height) return; + ptr.tx = (e.clientX - r.left) / r.width; + ptr.ty = 1 - (e.clientY - r.top) / r.height; // y-up, matches gl_FragCoord + ptr.thover = 1; + }; + const onLeave = () => { + ptr.thover = 0; + ptr.tx = 0.5; + ptr.ty = 0.5; + }; + if (pointer && !reduced) { + canvas.addEventListener("pointermove", onMove, { passive: true }); + canvas.addEventListener("pointerleave", onLeave, { passive: true }); + } + + // ---- draw --------------------------------------------------------------- + const draw = (t: number) => { + if (!program) return; + gl.useProgram(program); + resize(); + gl.uniform2f(loc("uResolution")!, w, h); + gl.uniform1f(loc("uTime")!, t); + gl.uniform3f(loc("uPointer")!, ptr.x * w, ptr.y * h, ptr.hover); + for (const [k, v] of Object.entries(live.current.uniforms ?? {})) { + const l = loc(k); + if (!l) continue; + if (typeof v === "number") gl.uniform1f(l, v); + else if (v.length === 2) gl.uniform2f(l, v[0], v[1]); + else if (v.length === 3) gl.uniform3f(l, v[0], v[1], v[2]); + else gl.uniform4f(l, v[0], v[1], v[2], v[3]); + } + gl.drawArrays(gl.TRIANGLES, 0, 3); + }; + + drawRef.current = draw; + + // ---- loop --------------------------------------------------------------- + let raf = 0; + let clock = 0; // accumulates only while actually rendering + let last = 0; + let visible = true; + + if (reduced) { + ptr.hover = 0; + draw(staticTime); + } else { + const frame = (now: number) => { + raf = requestAnimationFrame(frame); + if (!visible || document.hidden || live.current.paused) { + last = now; + return; + } + // Clamp dt so returning to a backgrounded tab never fast-forwards. + const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016); + last = now; + clock += dt * live.current.speed; + ptr.x += (ptr.tx - ptr.x) * 0.12; + ptr.y += (ptr.ty - ptr.y) * 0.12; + ptr.hover += (ptr.thover - ptr.hover) * 0.08; + draw(clock); + }; + raf = requestAnimationFrame(frame); + } + + // Suspend entirely while scrolled out of view — many shaders on one page + // stay cheap because only the visible ones burn GPU. + const io = + typeof IntersectionObserver !== "undefined" + ? new IntersectionObserver( + (entries) => { + visible = entries[0]?.isIntersecting ?? true; + if (visible) last = 0; + }, + { rootMargin: "120px" }, + ) + : null; + io?.observe(canvas); + + return () => { + cancelAnimationFrame(raf); + ro?.disconnect(); + io?.disconnect(); + canvas.removeEventListener("pointermove", onMove); + canvas.removeEventListener("pointerleave", onLeave); + canvas.removeEventListener("webglcontextlost", onLost); + canvas.removeEventListener("webglcontextrestored", onRestored); + if (program) gl.deleteProgram(program); + // Deliberately NOT calling WEBGL_lose_context here. A canvas returns the + // same context object on every getContext call, so losing it would poison + // every later mount on that canvas — React StrictMode re-runs effects in + // dev, and any prop in the dependency list re-runs them in production. + // The context is released when the canvas itself is collected. + }; + }, [fragment, signature, speed, dpr, pointer, reduced, staticTime]); + + return { ref, supported }; +} + +/** + * Parse `#rgb` / `#rrggbb` into a linear-ish 0..1 triple for shader uniforms. + * Returns mid-grey for anything unparseable so a typo never blanks the canvas. + */ +export function hexToRgb(hex: string): [number, number, number] { + let h = hex.trim().replace(/^#/, ""); + if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + if (!/^[0-9a-fA-F]{6}$/.test(h)) return [0.5, 0.5, 0.5]; + const n = parseInt(h, 16); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; +} diff --git a/registry/reactomega/ui/bento-grid.tsx b/registry/reactomega/ui/bento-grid.tsx new file mode 100644 index 0000000..325861c --- /dev/null +++ b/registry/reactomega/ui/bento-grid.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { createContext, useContext, useEffect, useId, useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +/** Called once per animation frame with grid-relative pointer coordinates. */ +type TileFrame = (gx: number, gy: number, inside: boolean) => void; + +interface BentoContextValue { + register: (fn: TileFrame) => () => void; + columns: number; + tilt: number; + spotlight: boolean; + reduced: boolean; +} + +const BentoContext = createContext({ + register: () => () => {}, + columns: 4, + tilt: 0, + spotlight: false, + reduced: true, +}); + +/** px beyond a tile's edge at which its border glow reaches zero. */ +const GLOW_FALLOFF = 220; + +export interface BentoGridProps extends React.HTMLAttributes { + /** Extra classes for the grid. @default undefined */ + className?: string; + /** `BentoTile` children. @default undefined */ + children?: React.ReactNode; + /** Columns at the large breakpoint — halved at `sm`, single column below. @default 4 */ + columns?: number; + /** Gutter in px. @default 16 */ + gap?: number; + /** Minimum height of each grid row in px. @default 140 */ + rowHeight?: number; + /** Track the pointer with a radial spotlight that crosses tile boundaries. @default true */ + spotlight?: boolean; + /** Spotlight colour at its centre. @default "rgba(124,92,255,.20)" */ + spotlightColor?: string; + /** Maximum per-tile tilt in degrees, 0 disables. @default 6 */ + tilt?: number; +} + +/** + * BentoGrid — a responsive bento layout that behaves like one surface rather + * than a pile of independent cards. The grid owns the *only* pointer listener: + * on each animation frame it reads its own live `getBoundingClientRect()` and + * publishes grid-relative coordinates as the inherited custom properties + * `--ro-gx` / `--ro-gy`. Because custom properties cascade, every tile's + * spotlight is pure CSS — each tile subtracts its own `offsetLeft` / `offsetTop` + * (`--ro-ox` / `--ro-oy`) inside `calc()`, so a single radial gradient appears + * continuous across gutters with zero per-tile listeners. Effects that CSS + * cannot express from those variables — the 3D tilt and the proximity border + * glow — are driven by the same frame: tiles subscribe a callback and the grid + * fans out to them, writing `style.transform` directly instead of through React + * state. Only transforms and custom properties are written, so layout stays + * clean and the per-frame offset reads stay cheap. + * + * Reduced motion detaches the listener entirely: no tracking, no tilt, no + * per-frame work. Tiles fall back to a static hover border so they still read + * as interactive, and links stay links. + */ +export function BentoGrid({ + className, + children, + columns = 4, + gap = 16, + rowHeight = 140, + spotlight = true, + spotlightColor = "rgba(124,92,255,.20)", + tilt = 6, + style, + ...rest +}: BentoGridProps) { + const reduced = usePrefersReducedMotion(); + const gridRef = useRef(null); + const subs = useRef>(new Set()); + const cls = "ro-bento-" + useId().replace(/[^a-zA-Z0-9-]/g, ""); + + const ctx = useMemo( + () => ({ + register: (fn: TileFrame) => { + subs.current.add(fn); + return () => { + subs.current.delete(fn); + }; + }, + columns: Math.max(1, Math.round(columns)), + tilt: reduced ? 0 : Math.max(0, tilt), + spotlight: spotlight && !reduced, + reduced, + }), + [columns, tilt, spotlight, reduced], + ); + + useEffect(() => { + const grid = gridRef.current; + if (!grid || reduced || (!spotlight && tilt <= 0)) return; + + let raf = 0; + let cx = 0; + let cy = 0; + let inside = false; + + const frame = () => { + raf = 0; + // Live rect every frame — the page scrolls and a cached rect goes stale. + const r = grid.getBoundingClientRect(); + const gx = inside ? cx - r.left : -9999; + const gy = inside ? cy - r.top : -9999; + grid.style.setProperty("--ro-gx", `${gx}px`); + grid.style.setProperty("--ro-gy", `${gy}px`); + subs.current.forEach((fn) => fn(gx, gy, inside)); + }; + const schedule = () => { + if (!raf) raf = requestAnimationFrame(frame); + }; + const onMove = (e: PointerEvent) => { + cx = e.clientX; + cy = e.clientY; + inside = true; + schedule(); + }; + const onLeave = () => { + inside = false; + schedule(); + }; + + grid.addEventListener("pointermove", onMove, { passive: true }); + grid.addEventListener("pointerleave", onLeave); + return () => { + cancelAnimationFrame(raf); + grid.removeEventListener("pointermove", onMove); + grid.removeEventListener("pointerleave", onLeave); + }; + }, [reduced, spotlight, tilt]); + + const cols = Math.max(1, Math.round(columns)); + const sm = Math.min(2, cols); + + return ( +
+ + {children} +
+ ); +} + +export interface BentoTileProps extends React.HTMLAttributes { + /** Extra classes for the tile. @default undefined */ + className?: string; + /** Tile content. @default undefined */ + children?: React.ReactNode; + /** Columns to span at the large breakpoint, clamped to the grid width. @default 1 */ + colSpan?: number; + /** Rows to span at the large breakpoint. @default 1 */ + rowSpan?: number; + /** Render as a link. Tiles with an href are real anchors and keyboard reachable. @default undefined */ + href?: string; +} + +/** + * BentoTile — one cell of a `BentoGrid`. Its span collapses to a single column + * on small screens (a 2-wide tile inside a 1-column grid would otherwise create + * a phantom track), widens to at most two at `sm`, and takes its full span at + * `lg`. It subscribes to the grid's shared pointer frame to write its own tilt + * transform and `--ro-glow` proximity value, and exposes its `offsetLeft` / + * `offsetTop` as custom properties so the grid-wide spotlight can be positioned + * from CSS alone. With an `href` it renders an ``; without one it is a plain + * container, never a div pretending to be a button. + */ +export function BentoTile({ + className, + children, + colSpan = 1, + rowSpan = 1, + href, + style, + ...rest +}: BentoTileProps) { + const { register, columns, tilt, spotlight, reduced } = useContext(BentoContext); + const ref = useRef(null); + const cls = "ro-tile-" + useId().replace(/[^a-zA-Z0-9-]/g, ""); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const sync = () => { + el.style.setProperty("--ro-ox", `${el.offsetLeft}px`); + el.style.setProperty("--ro-oy", `${el.offsetTop}px`); + }; + sync(); + if (reduced) return; + + const onFrame: TileFrame = (gx, gy, inside) => { + const ox = el.offsetLeft; + const oy = el.offsetTop; + const w = el.offsetWidth || 1; + const h = el.offsetHeight || 1; + el.style.setProperty("--ro-ox", `${ox}px`); + el.style.setProperty("--ro-oy", `${oy}px`); + + const dx = Math.max(ox - gx, 0, gx - (ox + w)); + const dy = Math.max(oy - gy, 0, gy - (oy + h)); + const dist = Math.hypot(dx, dy); + el.style.setProperty("--ro-glow", inside ? Math.max(0, 1 - dist / GLOW_FALLOFF).toFixed(3) : "0"); + + if (tilt > 0) { + if (inside && dist === 0) { + const px = (gx - ox) / w; + const py = (gy - oy) / h; + el.style.transform = `perspective(900px) rotateX(${((0.5 - py) * 2 * tilt).toFixed(2)}deg) rotateY(${( + (px - 0.5) * + 2 * + tilt + ).toFixed(2)}deg)`; + } else { + el.style.transform = "perspective(900px) rotateX(0deg) rotateY(0deg)"; + } + } + }; + const off = register(onFrame); + return () => off(); + }, [register, tilt, reduced]); + + const c = Math.max(1, Math.min(Math.round(colSpan), Math.max(1, columns))); + const sm = Math.min(c, 2); + const r = Math.max(1, Math.round(rowSpan)); + + const shell = cn( + "group relative isolate block overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5", + "outline-none backdrop-blur will-change-transform", + "focus-visible:ring-2 focus-visible:ring-[#7c5cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#06060a]", + reduced && "transition-colors hover:border-white/25", + cls, + className, + ); + + const spans = ( + + ); + + const inner = ( + <> + {spotlight && ( +
+ )} + {!reduced && ( +
+ )} +
{children}
+ + ); + + const inlineStyle: React.CSSProperties = { + transition: reduced ? undefined : "transform .18s cubic-bezier(.2,.7,.3,1)", + ...style, + }; + + if (href) { + return ( + <> + {spans} +
)} + href={href} + ref={(el) => { + ref.current = el; + }} + className={shell} + style={inlineStyle} + > + {inner} + + + ); + } + + return ( + <> + {spans} +
{ + ref.current = el; + }} + className={shell} + style={inlineStyle} + > + {inner} +
+ + ); +} diff --git a/registry/reactomega/ui/card-swap.tsx b/registry/reactomega/ui/card-swap.tsx new file mode 100644 index 0000000..4f06927 --- /dev/null +++ b/registry/reactomega/ui/card-swap.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { Children, useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface CardSwapProps extends Omit, "onChange" | "children"> { + /** Cards to stack. Each child becomes one card face. */ + children: React.ReactNode; + /** Extra classes for the deck container. @default undefined */ + className?: string; + /** ms the front card holds before the deck advances. @default 3400 */ + interval?: number; + /** Floor height of the stage in px — the deck needs room for its depth. @default 280 */ + minHeight?: number; + /** px each card behind the front is nudged right (and up). @default 26 */ + offset?: number; + /** px of Z distance between neighbouring cards. @default 62 */ + depth?: number; + /** Degrees of Y-rotation added per card of depth. @default 5 */ + rotation?: number; + /** Advance the deck on a timer. @default true */ + autoplay?: boolean; + /** Called with the new front-card index whenever the deck moves. @default undefined */ + onChange?: (index: number) => void; +} + +/** How many cards behind the front stay visible (and get reserved space). */ +const VISIBLE_LAYERS = 3; +/** ms the outgoing card spends flying forward before the deck re-indexes. */ +const PEEL_MS = 260; +/** ms every card takes to glide to its new slot. */ +const SETTLE_MS = 520; + +/** + * CardSwap — a 3D deck where cards recede into the distance and the front one + * peels away to reveal the next. Every card sits in the same absolutely + * positioned stage under a shared `perspective`; its slot is derived purely + * from `d`, its distance from the front, as + * `translate3d(d·offset, -d·offset·0.55, -d·depth) rotateY(d·rotation)`, so the + * whole deck is one pure function of the active index and CSS transitions do + * the interpolation. Advancing runs in two phases: the outgoing card is thrown + * forward on the top z-layer for `PEEL_MS`, then the index advances and it + * drops to the back z-layer and slides home behind the stack. The stage is + * inset by the total stack offset so cards never clip out of the container, + * whatever children you pass. + * + * Accessibility: every card is a real ` + ); + })} +
+
+ {n ? `Card ${active + 1} of ${n}` : "Empty deck"} +
+
+ ); +} diff --git a/registry/reactomega/ui/caustics.tsx b/registry/reactomega/ui/caustics.tsx new file mode 100644 index 0000000..2cd110b --- /dev/null +++ b/registry/reactomega/ui/caustics.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface CausticsProps { + className?: string; + /** Colour of the focused light. @default "#a9c4ff" */ + color?: string; + /** Colour of the unlit floor. @default "#06060a" */ + background?: string; + /** Brightness of the filaments. @default 1 */ + intensity?: number; + /** Zoom of the caustic web — higher packs more cells in. @default 1.35 */ + scale?: number; + /** Speed of the water above. @default 1 */ + speed?: number; +} + +/** + * Caustics — the net of focused light that a rippling water surface throws onto + * the floor beneath it. + * + * Built the way caustics are actually cheap to fake: a coordinate is folded + * back on itself five times, each pass displacing it by sines and cosines of + * its own components at a different temporal rate, and each pass accumulates + * the reciprocal distance to that folded position. Summing inverse distances + * is what concentrates energy into thin bright filaments where many folds + * happen to land together — the same reason real caustics are the envelope of + * refracted rays. The accumulation is then gamma-crushed hard so the mid-tones + * fall away and only the focus lines survive, and the crush exponent differs + * slightly per channel so the filaments carry a faint prismatic edge. The + * pointer drops a decaying radial phase wave into the surface, which bends the + * web outward around the cursor. + */ +export function Caustics({ + className, + color = "#a9c4ff", + background = "#06060a", + intensity = 1, + scale = 1.35, + speed = 1, +}: CausticsProps) { + const uniforms = useMemo( + () => ({ + uColor: hexToRgb(color), + uBg: hexToRgb(background), + uIntensity: intensity, + uScale: scale, + }), + [color, background, intensity, scale], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.42 + 23.0; + // The fold works on a large offset coordinate — at small |p| the reciprocal + // distance saturates on every pass and the field flattens to a single tone. + vec2 p = uv * uScale * 6.2831853 - 250.0; + + // A stone in the water above: a radial phase wave that displaces the sample + // point, so the whole web refracts outward from the cursor rather than just + // getting brighter under it. + vec2 rel = uv - pc; + float r = length(rel); + float ripple = uPointer.z * sin(r * 24.0 - uTime * 4.6) * exp(-r * 4.5) * 0.85; + p += normalize(rel + 1e-5) * ripple; + + // Fold the coordinate into itself; accumulate inverse distance each pass. + vec2 q = p; + float c = 1.0; + const float INTEN = 0.0045; + for (int n = 0; n < 5; n++) { + float tn = t * (1.0 - (3.2 / float(n + 1))); + q = p + vec2(cos(tn - q.x) + sin(tn + q.y), sin(tn - q.y) + cos(tn + q.x)); + float sx = sin(q.x + tn) / INTEN; + float sy = cos(q.y + tn) / INTEN; + c += 1.0 / length(vec2(p.x / (sx + 1e-4), p.y / (sy + 1e-4))); + } + c = 1.16 - pow(c / 5.0, 1.42); + // Clamp before the gamma crush: c is an unbounded reciprocal-distance sum, + // and pow(a, 7.9) on anything above 1.0 runs away to pure white. + float a = clamp(abs(c), 0.0, 1.0); + + // Slightly different crush per channel: blue filaments end up marginally + // wider than red, which reads as dispersion along every focus line. + vec3 web = vec3(pow(a, 9.4), pow(a, 8.6), pow(a, 7.6)) * uIntensity; + + vec3 col = uBg + uColor * web * 1.45; + col += uColor * 0.07 * pow(a, 3.4) * uIntensity; // the broad underwater wash + col += uColor * uPointer.z * 0.15 * exp(-r * 5.5); // glow where the stone fell + + col = 1.0 - exp(-col * 1.9); // tone map — filaments stay bright, nothing clips + col *= 1.0 - 0.34 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/circular-gallery.tsx b/registry/reactomega/ui/circular-gallery.tsx new file mode 100644 index 0000000..6c0a55b --- /dev/null +++ b/registry/reactomega/ui/circular-gallery.tsx @@ -0,0 +1,400 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface CircularGalleryItem { + /** Image URL. */ + src: string; + /** Alternative text. Use "" for purely decorative imagery. */ + alt: string; + /** Caption shown under the image and announced as the item's name. */ + label: string; +} + +export interface CircularGalleryProps extends Omit, "onSelect" | "children"> { + /** Extra classes for the gallery shell. @default undefined */ + className?: string; + /** Items placed evenly around the cylinder. @default [] */ + items?: CircularGalleryItem[]; + /** Cylinder radius in px — larger is a flatter arc. @default 420 */ + radius?: number; + /** Width of one item in px. @default 220 */ + itemWidth?: number; + /** Settle to the nearest item once the spin decays. @default true */ + snap?: boolean; + /** Per-frame velocity multiplier after release, 0–1. @default 0.94 */ + friction?: number; + /** Idle spin in degrees per second, 0 disables. @default 0 */ + autoRotate?: number; + /** Fires when an item is chosen. @default undefined */ + onSelect?: (index: number, item: CircularGalleryItem) => void; +} + +/** px of pointer travel before a press becomes a drag rather than a tap. */ +const DRAG_SLOP = 4; + +const mod = (a: number, n: number) => ((a % n) + n) % n; + +/** + * CircularGallery — items pinned to the surface of a horizontal cylinder in CSS + * 3D. Each item is fixed at `rotateY(i · 360/n) translateZ(radius)`, and only + * the stage they share is animated: `translateZ(-radius) rotateY(rot)` pushes + * the cylinder back by its own radius so the front item lands on the screen + * plane at true size instead of looming at the camera. Dragging converts + * pointer travel to rotation through the arc length of the cylinder + * (`360 / 2πr` degrees per pixel), so the surface tracks the finger 1:1 whatever + * the radius. Release hands the last frame's delta to an inertial integrator + * that decays by `friction` each frame and then, if `snap` is on, eases to the + * nearest multiple of the angle step — chosen as the equivalent angle closest + * to the current rotation, so it never unwinds the long way round. Depth is + * read back from each item's world angle: `cos` of the angle to the viewer + * drives opacity and scale, and items past the horizon lose pointer events so + * you cannot click the back of the cylinder through the front. Rotation is + * written straight to `style.transform` every frame; only the active index is + * React state. + * + * Accessibility: every item is a real ` + ))} +
+ + +
+ + +
+ +
+ {n ? `${items[active]?.label ?? ""}, item ${active + 1} of ${n}` : "Empty gallery"} +
+ + ); +} diff --git a/registry/reactomega/ui/crosshair.tsx b/registry/reactomega/ui/crosshair.tsx new file mode 100644 index 0000000..4a1449c --- /dev/null +++ b/registry/reactomega/ui/crosshair.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface CrosshairProps { + /** Content the crosshair is laid over. */ + children?: React.ReactNode; + className?: string; + /** Hairline and reticle color. @default "#7c5cff" */ + color?: string; + /** Hairline weight in px. @default 1 */ + thickness?: number; + /** Elements the crosshair locks onto. @default "[data-snap]" */ + snapSelector?: string; + /** Px from a target's edge at which the lock engages. @default 80 */ + snapDistance?: number; + /** Show the live coordinate readout. @default true */ + showCoords?: boolean; +} + +/** + * Crosshair — a precision-instrument overlay: full-bleed hairlines tracking the + * pointer, a corner-bracket reticle, and a monospace readout of the pointer's + * position in container space. + * + * Its one real trick is snapping. Each frame the nearest element matching + * `snapSelector` is measured with the clamped point-to-rectangle distance — zero + * while the pointer is inside the target, so a hover always locks — and if that + * is within `snapDistance` the crosshair acquires it. Acquisition drives a single + * eased 0..1 value that the hairline position interpolates along, from the raw + * pointer to the target's centre, while the reticle simultaneously grows from an + * 18px box into a bracket around the target's bounding box. One scalar, both + * behaviours, so the lock can never half-engage. + * + * Every rect — the container's and each target's — is read live each frame, so + * the instrument stays calibrated through scrolls, resizes and layout shifts, and + * targets added after mount are picked up automatically. Box dimensions are only + * written when they actually change, keeping the steady state to pure transforms. + * The loop idles once the lock has settled. The overlay is inert and aria-hidden; + * the children below it stay fully clickable and keyboard reachable. Reduced + * motion keeps the snapping but removes the glide, and coarse pointers get the + * children with no overlay at all. + */ +export function Crosshair({ + children, + className, + color = "#7c5cff", + thickness = 1, + snapSelector = "[data-snap]", + snapDistance = 80, + showCoords = true, +}: CrosshairProps) { + const wrapRef = useRef(null); + const layerRef = useRef(null); + const hRef = useRef(null); + const vRef = useRef(null); + const boxRef = useRef(null); + const labelRef = useRef(null); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + useEffect(() => { + if (!fine) return; + const wrap = wrapRef.current; + const layer = layerRef.current; + const hLine = hRef.current; + const vLine = vRef.current; + const box = boxRef.current; + if (!wrap || !layer || !hLine || !vLine || !box) return; + + let safeSelector = snapSelector; + try { + wrap.querySelector(snapSelector); + } catch { + safeSelector = ""; + } + + const BASE = 18; // reticle size with nothing acquired + let px = 0; + let py = 0; + let inside = false; + let lock = 0; // eased 0..1 acquisition + let lastW = -1; + let lastH = -1; + let lastText = ""; + let raf = 0; + let running = false; + + const step = () => { + const r = wrap.getBoundingClientRect(); + + // Nearest target by clamped point-to-rect distance (0 when pointer is inside it). + let tx = px; + let ty = py; + let tw = BASE; + let th = BASE; + let found = false; + if (inside && safeSelector) { + let best = Infinity; + const targets = wrap.querySelectorAll(safeSelector); + for (let i = 0; i < targets.length; i++) { + const b = targets[i].getBoundingClientRect(); + const left = b.left - r.left; + const top = b.top - r.top; + const dx = Math.max(left - px, 0, px - (left + b.width)); + const dy = Math.max(top - py, 0, py - (top + b.height)); + const d = Math.hypot(dx, dy); + if (d <= snapDistance && d < best) { + best = d; + tx = left + b.width / 2; + ty = top + b.height / 2; + tw = b.width + 10; + th = b.height + 10; + found = true; + } + } + } + + const target = found ? 1 : 0; + if (reduced) lock = target; + else lock += (target - lock) * 0.3; + const e = lock * lock * (3 - 2 * lock); // smoothstep + + const cx = px + (tx - px) * e; + const cy = py + (ty - py) * e; + const bw = BASE + (tw - BASE) * e; + const bh = BASE + (th - BASE) * e; + + hLine.style.transform = `translate3d(0, ${(cy - thickness / 2).toFixed(2)}px, 0)`; + vLine.style.transform = `translate3d(${(cx - thickness / 2).toFixed(2)}px, 0, 0)`; + box.style.transform = `translate3d(${(cx - bw / 2).toFixed(2)}px, ${(cy - bh / 2).toFixed(2)}px, 0)`; + // Layout writes only when the bracket actually resizes. + if (Math.abs(bw - lastW) > 0.5) { + lastW = bw; + box.style.width = `${bw.toFixed(1)}px`; + } + if (Math.abs(bh - lastH) > 0.5) { + lastH = bh; + box.style.height = `${bh.toFixed(1)}px`; + } + + const label = labelRef.current; + if (label) { + const text = `${Math.round(cx)}, ${Math.round(cy)}`; + if (text !== lastText) { + lastText = text; + label.textContent = text; + } + // Keep the readout inside the box by flipping it near the far edges. + const flipX = cx > r.width - 96; + const flipY = cy > r.height - 40; + label.style.transform = + `translate3d(${(cx + (flipX ? -14 : 14)).toFixed(1)}px, ${(cy + (flipY ? -14 : 14)).toFixed(1)}px, 0)` + + ` translate(${flipX ? "-100%" : "0"}, ${flipY ? "-100%" : "0"})`; + } + + if (!reduced && Math.abs(target - lock) > 0.002) raf = requestAnimationFrame(step); + else running = false; + }; + + const ensure = () => { + if (!running) { + running = true; + raf = requestAnimationFrame(step); + } + }; + + const onMove = (e: PointerEvent) => { + const r = wrap.getBoundingClientRect(); + px = e.clientX - r.left; + py = e.clientY - r.top; + inside = true; + ensure(); + }; + const onEnter = () => { + inside = true; + layer.style.opacity = "1"; + ensure(); + }; + const onLeave = () => { + inside = false; + layer.style.opacity = "0"; + ensure(); + }; + + wrap.addEventListener("pointerenter", onEnter); + wrap.addEventListener("pointermove", onMove, { passive: true }); + wrap.addEventListener("pointerleave", onLeave); + ensure(); + + return () => { + cancelAnimationFrame(raf); + wrap.removeEventListener("pointerenter", onEnter); + wrap.removeEventListener("pointermove", onMove); + wrap.removeEventListener("pointerleave", onLeave); + }; + }, [fine, reduced, color, thickness, snapSelector, snapDistance, showCoords]); + + // borderStyle alone would default the unset sides to `medium` and draw a full + // box, so every side starts at zero width and the caller adds back just two. + const corner = (style: React.CSSProperties) => ( + + ); + + return ( +
+ {children} + {fine && ( +
+
+
+
+ {corner({ left: -1, top: -1, borderLeftWidth: thickness + 1, borderTopWidth: thickness + 1 })} + {corner({ right: -1, top: -1, borderRightWidth: thickness + 1, borderTopWidth: thickness + 1 })} + {corner({ left: -1, bottom: -1, borderLeftWidth: thickness + 1, borderBottomWidth: thickness + 1 })} + {corner({ right: -1, bottom: -1, borderRightWidth: thickness + 1, borderBottomWidth: thickness + 1 })} +
+ {showCoords && ( +
+ )} +
+ )} +
+ ); +} diff --git a/registry/reactomega/ui/curl-flow.tsx b/registry/reactomega/ui/curl-flow.tsx new file mode 100644 index 0000000..ef3c22e --- /dev/null +++ b/registry/reactomega/ui/curl-flow.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface CurlFlowProps { + className?: string; + /** Colour of the silk. @default "#9b8cff" */ + tint?: string; + /** Colour in the folds. @default "#06060a" */ + background?: string; + /** Zoom of the flow field — higher gives narrower bands. @default 2.2 */ + scale?: number; + /** Advection steps, 1..8. More steps means longer, silkier streamlines. @default 5 */ + steps?: number; + /** Flow speed multiplier. @default 1 */ + speed?: number; + /** Band hardness — higher pinches the highlights. @default 1.15 */ + contrast?: number; +} + +/** + * CurlFlow — wide bands of silk folding through each other, driven by a real + * curl-noise field. + * + * A three-octave FBM is treated as a scalar stream function ψ. The velocity is + * the perpendicular of its gradient, (∂ψ/∂y, −∂ψ/∂x), which is divergence-free + * by construction — that is the whole trick, and it is why the flow swirls and + * shears without ever piling up into a source or draining into a sink the way + * a raw noise-vector field does. Each pixel walks its coordinate a few steps + * along that velocity, so it ends up sampling the value it had upstream; ψ read + * at the advected position is therefore stretched along the streamlines, and a + * sine of it becomes bands that follow the flow. The highlight is anisotropic: + * brightness depends on the alignment of the local tangent with the light, the + * way sheen on real silk depends on fibre direction rather than surface normal. + * The pointer adds a genuine vortex — a tangential term about the cursor — + * into the velocity before each step, so the fabric twists rather than dents. + */ +export function CurlFlow({ + className, + tint = "#9b8cff", + background = "#06060a", + scale = 2.2, + steps = 5, + speed = 1, + contrast = 1.15, +}: CurlFlowProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uBg: hexToRgb(background), + uScale: scale, + uSteps: steps, + uContrast: contrast, + }), + [tint, background, scale, steps, contrast], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm3(vec2 p) { + float a = 0.5; + float v = 0.0; + for (int i = 0; i < 3; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +// Scalar stream function. +float potential(vec2 p, float t) { + return fbm3(p + vec2(t * 0.09, t * 0.16)); +} + +// Curl of that potential: perpendicular of the gradient, so divergence is zero +// and the field can only rotate and shear. Forward differences — three taps +// instead of the four a central difference would cost, and the bias is invisible. +vec2 curl(vec2 p, float t) { + const float e = 0.045; + float a = potential(p, t); + float gx = (potential(p + vec2(e, 0.0), t) - a) / e; + float gy = (potential(p + vec2(0.0, e), t) - a) / e; + return vec2(gy, -gx); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + float t = uTime * 0.3; + + vec2 q = uv * uScale; + vec2 pcs = pc * uScale; + float steps = clamp(uSteps, 1.0, 8.0); + float h = 0.9 / steps; // total advected distance stays constant + vec2 vel = vec2(0.0); + + for (int i = 0; i < 8; i++) { + if (float(i) >= steps) break; + vel = curl(q, t); + vec2 r = q - pcs; + float w = uPointer.z * exp(-dot(r, r) * 1.7); + vel += vec2(-r.y, r.x) * w * 3.4; // vortex injected at the cursor + q += vel * h * 0.60; + } + + float s = potential(q, t); + + // Anisotropic sheen: silk is bright where the fibre tangent lines up with the + // light, so the highlight rides the flow direction, not a surface normal. + vec2 tangent = normalize(vel + vec2(1e-5, 1e-5)); + float aniso = pow(abs(dot(tangent, vec2(0.55, 0.84))), 3.5); + + float band = 0.5 + 0.5 * sin(s * 15.0 + t * 1.7); + band = pow(band, max(uContrast, 0.05) * 3.0); + + float shade = clamp(band * (0.42 + 0.85 * aniso) + 0.05 * length(vel), 0.0, 1.0); + + vec3 hi = clamp(uTint * 1.45 + 0.16, 0.0, 1.0); + vec3 col = mix(uBg, uTint, shade); + col += hi * pow(shade, 4.5) * 0.55; + + col *= 1.0 - 0.38 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/elastic-cursor.tsx b/registry/reactomega/ui/elastic-cursor.tsx new file mode 100644 index 0000000..f5305e0 --- /dev/null +++ b/registry/reactomega/ui/elastic-cursor.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ElasticCursorProps { + className?: string; + /** Diameter of the lead blob in px. @default 34 */ + size?: number; + /** Blob color. @default "#7c5cff" */ + color?: string; + /** How far the blob stretches at full speed, as a fraction of its size. @default 0.55 */ + stretch?: number; + /** Spring acceleration, 0..1 — higher chases the pointer harder. @default 0.3 */ + stiffness?: number; + /** Spring damping, 0..1 — lower settles with less wobble. @default 0.5 */ + damping?: number; + /** Number of smaller blobs chained behind the lead one. @default 2 */ + trail?: number; + /** Merge the blobs through an SVG goo filter. @default true */ + goo?: boolean; +} + +/** + * ElasticCursor — a gooey blob that deforms along its own velocity vector. Each + * frame the blob's spring velocity gives both a direction and a speed: the blob + * rotates to `atan2(vy, vx)` and scales by `1 + k` along that axis while scaling + * by `1 / (1 + k)` across it, so the product stays 1 and the blob conserves its + * area — it stretches into a teardrop under a flick and relaxes back to a perfect + * circle at rest, never inflating. + * + * Speed maps to stretch through `1 - exp(-v / 14)`, a saturating curve, so there + * is no clamp discontinuity when the pointer is thrown across the screen. Trailing + * blobs are a follower chain — each one springs toward the one ahead with reduced + * stiffness — and an SVG goo filter (blur plus an alpha contrast ramp) welds them + * into a single surface that necks and snaps. The filter id comes from `useId()` + * so several instances never collide. + * + * The loop idles once every blob has caught up. Coarse pointers render nothing; + * reduced motion pins every blob to the pointer with no stretch and no rotation. + */ +export function ElasticCursor({ + className, + size = 34, + color = "#7c5cff", + stretch = 0.55, + stiffness = 0.3, + damping = 0.5, + trail = 2, + goo = true, +}: ElasticCursorProps) { + const rootRef = useRef(null); + const blobRefs = useRef<(HTMLDivElement | null)[]>([]); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + const filterId = "ro-elastic-" + useId().replace(/[^a-zA-Z0-9-]/g, ""); + + const count = Math.max(1, Math.round(trail) + 1); + const diameter = (i: number) => Math.round(size * Math.max(0.35, 1 - 0.22 * i)); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + useEffect(() => { + if (!fine) return; + const root = rootRef.current; + if (!root) return; + const blobs = blobRefs.current.slice(0, count).filter(Boolean) as HTMLDivElement[]; + if (!blobs.length) return; + + const state = blobs.map(() => ({ x: -9999, y: -9999, vx: 0, vy: 0, a: 0 })); + let px = -9999; + let py = -9999; + let seen = false; + let raf = 0; + let running = false; + + const step = () => { + const r = root.getBoundingClientRect(); + let active = false; + + for (let i = 0; i < blobs.length; i++) { + const s = state[i]; + // Blob 0 chases the pointer; every other blob chases the one in front. + const tx = i === 0 ? px : state[i - 1].x; + const ty = i === 0 ? py : state[i - 1].y; + + if (reduced) { + s.x = tx; + s.y = ty; + s.vx = 0; + s.vy = 0; + } else { + const k = stiffness * Math.pow(0.72, i); + s.vx = (s.vx + (tx - s.x) * k) * damping; + s.vy = (s.vy + (ty - s.y) * k) * damping; + s.x += s.vx; + s.y += s.vy; + } + + const speed = Math.hypot(s.vx, s.vy); + // Saturating response: fast flicks approach full stretch without a clamp. + const t = reduced ? 0 : 1 - Math.exp(-speed / 14); + const sx = 1 + stretch * t; + const sy = 1 / sx; // area preserved: sx * sy === 1 + if (speed > 0.6) s.a = (Math.atan2(s.vy, s.vx) * 180) / Math.PI; + + blobs[i].style.transform = + `translate3d(${(s.x - r.left).toFixed(2)}px, ${(s.y - r.top).toFixed(2)}px, 0)` + + ` translate(-50%, -50%) rotate(${s.a.toFixed(2)}deg) scale(${sx.toFixed(3)}, ${sy.toFixed(3)})`; + + if (Math.abs(tx - s.x) > 0.05 || Math.abs(ty - s.y) > 0.05 || speed > 0.05) active = true; + } + + if (active) raf = requestAnimationFrame(step); + else running = false; + }; + + const ensure = () => { + if (!running) { + running = true; + raf = requestAnimationFrame(step); + } + }; + + const onMove = (e: PointerEvent) => { + px = e.clientX; + py = e.clientY; + if (!seen) { + seen = true; + for (const s of state) { + s.x = px; + s.y = py; + } + root.style.opacity = "1"; + } + ensure(); + }; + const onOut = (e: PointerEvent) => { + if (e.relatedTarget) return; + root.style.opacity = "0"; + seen = false; + }; + const onBlur = () => { + root.style.opacity = "0"; + seen = false; + }; + + document.addEventListener("pointermove", onMove, { passive: true }); + document.addEventListener("pointerout", onOut, { passive: true }); + window.addEventListener("blur", onBlur); + ensure(); + + return () => { + cancelAnimationFrame(raf); + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerout", onOut); + window.removeEventListener("blur", onBlur); + }; + }, [fine, reduced, count, size, stretch, stiffness, damping]); + + if (!fine) return null; + + return ( +
+ {goo && !reduced && ( + + + + + + + + + )} + {Array.from({ length: count }).map((_, i) => ( +
{ + blobRefs.current[i] = el; + }} + className="absolute left-0 top-0 rounded-full will-change-transform" + style={{ width: diameter(i), height: diameter(i), background: color }} + /> + ))} +
+ ); +} diff --git a/registry/reactomega/ui/elastic-slider.tsx b/registry/reactomega/ui/elastic-slider.tsx new file mode 100644 index 0000000..04cd084 --- /dev/null +++ b/registry/reactomega/ui/elastic-slider.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ElasticSliderProps + extends Omit, "onChange" | "defaultValue" | "children"> { + /** Extra classes for the row that holds the icons and the track. @default undefined */ + className?: string; + /** Controlled value. @default undefined */ + value?: number; + /** Uncontrolled starting value. @default 50 */ + defaultValue?: number; + /** Lower bound. @default 0 */ + min?: number; + /** Upper bound. @default 100 */ + max?: number; + /** Quantisation step. @default 1 */ + step?: number; + /** Fires with every committed value. @default undefined */ + onChange?: (value: number) => void; + /** Maximum rubber-band overshoot in px at either end, 0 disables. @default 56 */ + elasticity?: number; + /** Formats the value for the readout and `aria-valuetext`. @default (v) => String(v) */ + formatValue?: (value: number) => string; + /** Node rendered before the track. @default undefined */ + leftIcon?: React.ReactNode; + /** Node rendered after the track. @default undefined */ + rightIcon?: React.ReactNode; +} + +/** Spring constants for the release snap-back. */ +const SPRING_K = 0.24; +const SPRING_DAMP = 0.7; + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +/** + * ElasticSlider — a slider whose track rubber-bands when you drag past either + * end. Pointer travel beyond the rail is converted to overshoot through + * `limit · (1 - e^(-excess / limit))`, an asymptotic curve: the first pixels + * past the end move almost freely, the last ones barely move at all, and the + * result can never exceed `elasticity` no matter how far you pull — the + * diminishing-returns feel of a real elastic band, which a linear clamp cannot + * produce. The overshoot becomes a `scaleX` on the whole track anchored to the + * opposite end (so the rail genuinely stretches rather than translating), with + * a reciprocal `scaleX` on the thumb to keep it circular. Releasing hands the + * overshoot to a damped spring integrated in `requestAnimationFrame`; the rail + * also swells on the Y axis while the pointer is down. All of that is written + * straight to `style.transform` each frame, while the value itself stays + * ordinary React state, and the rail width is re-read live so the maths + * survives a resizing or scrolling page. + * + * Accessibility: the track carries the full `role="slider"` contract — + * `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext` / + * `aria-orientation` — and is focusable, with Arrow keys stepping, + * PageUp/PageDown taking a larger jump, and Home/End going to the bounds. + * Dragging uses Pointer Events with pointer capture and `touch-action: none`, + * so it works identically with touch, pen and mouse. Reduced motion keeps every + * one of those interactions and simply removes the stretch, the swell and the + * spring. + */ +export function ElasticSlider({ + className, + value, + defaultValue = 50, + min = 0, + max = 100, + step = 1, + onChange, + elasticity = 56, + formatValue, + leftIcon, + rightIcon, + "aria-label": ariaLabel = "Value", + "aria-labelledby": ariaLabelledBy, + style, + ...rest +}: ElasticSliderProps) { + const reduced = usePrefersReducedMotion(); + const trackRef = useRef(null); + const stretchRef = useRef(null); + const railRef = useRef(null); + const thumbRef = useRef(null); + + const over = useRef(0); + const vel = useRef(0); + const swell = useRef(0); + const dragging = useRef(false); + const raf = useRef(0); + + const [internal, setInternal] = useState(defaultValue); + const current = value ?? internal; + + const span = max - min || 1; + const decimals = (() => { + const s = String(step); + const dot = s.indexOf("."); + return dot < 0 ? 0 : s.length - dot - 1; + })(); + const quantize = useCallback( + (v: number) => { + const snapped = Math.round((v - min) / step) * step + min; + return clamp(Number(snapped.toFixed(decimals)), min, max); + }, + [min, max, step, decimals], + ); + + const safe = clamp(current, min, max); + const pct = ((safe - min) / span) * 100; + const readout = formatValue ? formatValue(safe) : String(safe); + + const commit = useCallback( + (raw: number) => { + const next = quantize(raw); + if (value === undefined) setInternal(next); + if (next !== safe) onChange?.(next); + }, + [quantize, value, safe, onChange], + ); + + const paint = useCallback(() => { + const track = trackRef.current; + const stretch = stretchRef.current; + const rail = railRef.current; + const thumb = thumbRef.current; + if (!track || !stretch || !rail || !thumb) return; + // Live width every frame — a cached rect goes stale as the page reflows. + const w = track.getBoundingClientRect().width || 1; + const o = over.current; + const s = 1 + Math.abs(o) / w; + stretch.style.transformOrigin = o > 0 ? "left center" : "right center"; + stretch.style.transform = `scaleX(${s.toFixed(4)})`; + rail.style.transform = `scaleY(${(1 + swell.current * 0.7).toFixed(3)})`; + thumb.style.transform = `translate(-50%,-50%) scaleX(${(1 / s).toFixed(4)}) scale(${(1 + swell.current * 0.14).toFixed(3)})`; + }, []); + + const tick = useCallback(() => { + raf.current = 0; + if (!dragging.current) { + vel.current = (vel.current - over.current * SPRING_K) * SPRING_DAMP; + over.current += vel.current; + if (Math.abs(over.current) < 0.08 && Math.abs(vel.current) < 0.08) { + over.current = 0; + vel.current = 0; + } + } + swell.current += ((dragging.current ? 1 : 0) - swell.current) * 0.22; + if (swell.current < 0.004 && !dragging.current) swell.current = 0; + paint(); + if (dragging.current || over.current !== 0 || swell.current !== 0) { + raf.current = requestAnimationFrame(tick); + } + }, [paint]); + + const ensureLoop = useCallback(() => { + if (reduced) return; + if (!raf.current) raf.current = requestAnimationFrame(tick); + }, [reduced, tick]); + + useEffect(() => () => cancelAnimationFrame(raf.current), []); + + // Keep the rendered thumb geometry correct after value / size changes. + useEffect(() => { + if (reduced) return; + if (!raf.current) paint(); + }, [safe, reduced, paint]); + + const fromClientX = (clientX: number) => { + const track = trackRef.current; + if (!track) return; + const r = track.getBoundingClientRect(); + const w = r.width || 1; + const ratio = (clientX - r.left) / w; + commit(min + clamp(ratio, 0, 1) * span); + + if (reduced || elasticity <= 0) { + over.current = 0; + return; + } + const excess = ratio < 0 ? -ratio * w : ratio > 1 ? (ratio - 1) * w : 0; + const band = elasticity * (1 - Math.exp(-excess / elasticity)); + over.current = ratio < 0 ? -band : ratio > 1 ? band : 0; + }; + + const onPointerDown = (e: React.PointerEvent) => { + if (e.button !== undefined && e.button !== 0) return; + e.preventDefault(); + e.currentTarget.setPointerCapture?.(e.pointerId); + e.currentTarget.focus(); + dragging.current = true; + vel.current = 0; // drop any momentum left over from a previous snap-back + fromClientX(e.clientX); + ensureLoop(); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!dragging.current) return; + fromClientX(e.clientX); + ensureLoop(); + }; + + const endDrag = (e: React.PointerEvent) => { + if (!dragging.current) return; + dragging.current = false; + e.currentTarget.releasePointerCapture?.(e.pointerId); + ensureLoop(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + const big = Math.max(step * 10, span / 10); + let next: number | null = null; + switch (e.key) { + case "ArrowRight": + case "ArrowUp": + next = safe + step; + break; + case "ArrowLeft": + case "ArrowDown": + next = safe - step; + break; + case "PageUp": + next = safe + big; + break; + case "PageDown": + next = safe - big; + break; + case "Home": + next = min; + break; + case "End": + next = max; + break; + default: + return; + } + e.preventDefault(); + commit(clamp(next, min, max)); + }; + + return ( +
+ {leftIcon != null && ( + + {leftIcon} + + )} + +
+
+
+
+
+
+
+
+ + {rightIcon != null && ( + + {rightIcon} + + )} + + {readout} + +
+ ); +} diff --git a/registry/reactomega/ui/gooey-nav.tsx b/registry/reactomega/ui/gooey-nav.tsx new file mode 100644 index 0000000..384e15d --- /dev/null +++ b/registry/reactomega/ui/gooey-nav.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface GooeyNavItem { + /** Visible label. */ + label: string; + /** Stable value — also used as the tab's DOM id so a panel can point at it with `aria-labelledby`. */ + id: string; +} + +export interface GooeyNavProps extends Omit, "onChange" | "defaultValue"> { + /** Extra classes for the nav shell. @default undefined */ + className?: string; + /** The items to render, left to right. @default [] */ + items?: GooeyNavItem[]; + /** Controlled selected id. @default undefined */ + value?: string; + /** Uncontrolled starting id. @default first item's id */ + defaultValue?: string; + /** Fires with the newly selected id. @default undefined */ + onChange?: (id: string) => void; + /** Blob colour. @default "#7c5cff" */ + color?: string; + /** Blur radius fed to the goo filter — higher is more liquid. @default 9 */ + gooStrength?: number; + /** 0–1. How far the trailing blob lags, i.e. how much the blob elongates. @default 0.6 */ + stretch?: number; +} + +/** ms the leading blob takes to reach the new item. */ +const LEAD_MS = 380; + +/** + * GooeyNav — a pill navigation where a liquid indicator flows between items. + * The indicator is two identically coloured blobs sharing one SVG goo filter + * (`feGaussianBlur` → `feColorMatrix` alpha contrast, which re-hardens the + * blurred edges so overlapping shapes fuse). The leading blob reaches the new + * item in `LEAD_MS`; the trailing blob is given a longer duration and a small + * delay scaled by `stretch`, so mid-flight the two are apart and the filter + * renders them as one elongated capsule pointing back the way it came, which + * then settles into a single pill when the trail catches up. A brief scaleY + * squash on the leading blob adds the surface-tension snap. Geometry comes + * from the target button's `offsetLeft` / `offsetWidth` / `offsetTop` / + * `offsetHeight`, re-measured by a `ResizeObserver`, so the blob tracks + * reflows and font swaps without hard-coded sizes. + * + * Accessibility: labels live *outside* the filtered layer (a goo filter would + * smear text), so they stay crisp and selectable. The items are real + * ` + ))} + + ); +} diff --git a/registry/reactomega/ui/halftone-gradient.tsx b/registry/reactomega/ui/halftone-gradient.tsx new file mode 100644 index 0000000..3783cf8 --- /dev/null +++ b/registry/reactomega/ui/halftone-gradient.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface HalftoneGradientProps { + className?: string; + /** First gradient stop. @default "#7c5cff" */ + from?: string; + /** Last gradient stop — the midpoint is derived from both. @default "#4fd1ff" */ + to?: string; + /** Screen pitch in device pixels: dot spacing, or Bayer block size. @default 14 */ + dotSize?: number; + /** Screen angle in degrees. Also sets the gradient direction. @default 22 */ + angle?: number; + /** Drift speed of the gradient underneath the screen. @default 1 */ + speed?: number; + /** Rotated dot screen, or an ordered 4x4 Bayer matrix. @default "dots" */ + mode?: "dots" | "bayer"; +} + +/** + * HalftoneGradient — a drifting multi-stop gradient printed through a screen, + * so it resolves into discrete ink rather than a smooth blend. + * + * The continuous field underneath is a directional ramp perturbed by three + * detuned sines, run through a three-stop ramp (from → a lifted midpoint → to). + * That field is then destroyed on purpose. In "dots" mode each channel gets its + * own rotated dot screen 30° apart, exactly as CMY separations are angled in + * print — the offset is what produces rosettes instead of a moiré clash — and + * dot radius goes as √value so dot *area* stays linear in tone, which is why + * mid-greys do not print muddy. In "bayer" mode a 4×4 ordered dither matrix + * (computed arithmetically, no lookup table) biases a per-channel quantiser to + * four levels, giving flat risograph plates with a visible weave. Antialiasing + * is derived from the cell pitch rather than fwidth so cell seams stay clean. + * The pointer shrinks the local pitch, opening the screen up under the cursor. + */ +export function HalftoneGradient({ + className, + from = "#7c5cff", + to = "#4fd1ff", + dotSize = 14, + angle = 22, + speed = 1, + mode = "dots", +}: HalftoneGradientProps) { + const uniforms = useMemo( + () => ({ + uFrom: hexToRgb(from), + uTo: hexToRgb(to), + uCell: dotSize, + uAngle: (angle * Math.PI) / 180, + uMode: mode === "bayer" ? 1 : 0, + }), + [from, to, dotSize, angle, mode], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +vec2 rot2(vec2 p, float a) { + float c = cos(a); + float s = sin(a); + return vec2(c * p.x - s * p.y, s * p.x + c * p.y); +} + +// 2x2 then 4x4 ordered dither, built from the recursive definition instead of a +// table. Wrapped to 0..3 first so y*y never loses mantissa on tall buffers. +float bayer2(vec2 a) { + a = floor(a); + return fract(a.x * 0.5 + a.y * a.y * 0.75); +} + +float bayer4(vec2 a) { + a = mod(a, 4.0); + return bayer2(a * 0.5) * 0.25 + bayer2(a); +} + +// One rotated dot screen. Radius tracks sqrt(value) so covered *area* is linear +// in tone — the standard print correction. +float screen(vec2 frag, float cell, float ang, float value) { + vec2 q = rot2(frag, ang) / cell; + float d = length(fract(q) - 0.5); + float rad = sqrt(clamp(value, 0.0, 1.0)) * 0.52; + float aa = 1.4 / cell; + return smoothstep(rad + aa, rad - aa, d); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + float t = uTime; + + // Continuous field: a directional ramp with three detuned sines on top, so + // the stops never sit still and never repeat cleanly. + vec2 dir = vec2(cos(uAngle), sin(uAngle)); + float g = dot(uv, dir) * 1.15 + 0.5; + g += 0.20 * sin(uv.y * 3.3 - t * 0.61) + + 0.15 * sin(uv.x * 2.4 + t * 0.47) + + 0.09 * sin((uv.x + uv.y) * 5.1 - t * 0.83); + g = clamp(g, 0.0, 1.0); + + vec3 mid = clamp(mix(uFrom, uTo, 0.5) * 1.32 + 0.05, 0.0, 1.0); + vec3 grad = mix(mix(uFrom, mid, clamp(g * 2.0, 0.0, 1.0)), uTo, clamp(g * 2.0 - 1.0, 0.0, 1.0)); + + // Pointer opens the screen: finer pitch, a touch more ink. + vec2 rel = uv - pc; + float near = uPointer.z * exp(-dot(rel, rel) * 7.0); + float cell = max(uCell * mix(1.0, 0.38, near), 2.0); + + float v = (0.20 + 0.88 * g) * (1.0 - 0.40 * dot(uv, uv)) + 0.20 * near; + vec3 c = clamp(grad * v * 0.98, 0.0, 1.0); + + vec3 ink; + if (uMode > 0.5) { + float th = bayer4(floor(gl_FragCoord.xy / cell)) - 0.5; + ink = floor(c * 4.0 + th + 0.5) * 0.25 * 0.95; + } else { + vec3 cov = vec3(screen(gl_FragCoord.xy, cell, uAngle + 0.2618, c.r), + screen(gl_FragCoord.xy, cell, uAngle + 0.7854, c.g), + screen(gl_FragCoord.xy, cell, uAngle + 1.3090, c.b)); + // Separated screens are what makes a rosette, but taken literally every dot + // lands on a pure primary. Blending back toward shared coverage keeps the + // interference pattern while the colour stays in the gradient. + float shared = dot(cov, vec3(0.3333)); + ink = mix(cov, vec3(shared), 0.62) * grad * 1.02; + } + + vec3 paper = uFrom * 0.035 + vec3(0.016, 0.016, 0.026); + vec3 col = paper + ink; + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.010; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/image-trail.tsx b/registry/reactomega/ui/image-trail.tsx new file mode 100644 index 0000000..3559405 --- /dev/null +++ b/registry/reactomega/ui/image-trail.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ImageTrailProps { + /** Image sources dropped along the pointer path, cycled in order. */ + images: string[]; + /** Content rendered underneath the trail. */ + children?: React.ReactNode; + className?: string; + /** Size of the recycled image pool — the most that can be on screen at once. @default 8 */ + count?: number; + /** Px the pointer must travel before the next image is dropped. @default 60 */ + threshold?: number; + /** Lifetime of each image in ms. @default 750 */ + duration?: number; + /** Image width in px; height follows the intrinsic ratio. @default 160 */ + width?: number; + /** Max tilt in degrees, taken from the direction of travel. @default 12 */ + rotation?: number; +} + +/** + * ImageTrail — images fall out of the pointer as it crosses the container, each + * one popping in, tilting into the direction of travel, then fading and shrinking + * away. + * + * The detail that makes this feel right is distance-thresholded emission: nothing + * is dropped per pointermove event. Instead the distance the pointer has travelled + * is banked, and images are dropped at exact multiples of `threshold` measured + * along the path, with the remainder carried into the next event — so one fast + * event can lay down several images in a row and spacing stays exactly `threshold` + * whatever the pointer speed. Event-rate emission would clot on a slow drag and go + * dotted on a flick; even a naive accumulator that resets to the event position + * would round spacing up to a whole multiple of the per-event travel. + * + * Images come from a fixed pool of `count` DOM nodes written round-robin, so a long + * drag never allocates — the oldest image is simply reclaimed. Every position is + * measured against the container's live rect at drop time, so the trail lands + * correctly on a scrolled page, and sources are pre-decoded on mount so the first + * pass does not flicker. The loop idles when the last image dies. Reduced motion + * and coarse pointers drop nothing at all; the children render untouched. + */ +export function ImageTrail({ + images, + children, + className, + count = 8, + threshold = 60, + duration = 750, + width = 160, + rotation = 12, +}: ImageTrailProps) { + const wrapRef = useRef(null); + const itemRefs = useRef<(HTMLImageElement | null)[]>([]); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + // Warm the cache so the first lap of the pool does not pop in blank. + useEffect(() => { + if (typeof window === "undefined") return; + for (const src of images) { + const img = new Image(); + img.src = src; + } + }, [images]); + + useEffect(() => { + if (!fine || reduced || !images.length) return; + const wrap = wrapRef.current; + if (!wrap) return; + const items = itemRefs.current.slice(0, count).filter(Boolean) as HTMLImageElement[]; + if (!items.length) return; + + const slots = items.map(() => ({ born: -1, x: 0, y: 0, deg: 0 })); + const gap = Math.max(1, threshold); // a zero threshold would divide by zero + let head = 0; + let imageIndex = 0; + let lastX = 0; + let lastY = 0; + let acc = 0; // distance banked since the last drop, remainder carried forward + let primed = false; + let raf = 0; + let running = false; + + const step = () => { + const now = performance.now(); + let alive = false; + + for (let i = 0; i < items.length; i++) { + const s = slots[i]; + if (s.born < 0) continue; + const p = (now - s.born) / duration; + if (p >= 1) { + s.born = -1; + items[i].style.opacity = "0"; + continue; + } + alive = true; + // Quick pop in, long hold, accelerating fade out. + const rise = Math.min(1, p / 0.09); + const scale = (0.82 + 0.18 * rise) * (1 - 0.18 * p); + items[i].style.opacity = (rise * (1 - p * p)).toFixed(3); + items[i].style.transform = + `translate3d(${s.x.toFixed(1)}px, ${s.y.toFixed(1)}px, 0)` + + ` translate(-50%, -50%) rotate(${s.deg.toFixed(2)}deg) scale(${scale.toFixed(3)})`; + } + + if (alive) raf = requestAnimationFrame(step); + else running = false; + }; + + const ensure = () => { + if (!running) { + running = true; + raf = requestAnimationFrame(step); + } + }; + + const emit = (x: number, y: number, dx: number) => { + const i = head % items.length; + head++; + const s = slots[i]; + s.born = performance.now(); + s.x = x; + s.y = y; + // Lean into the swipe: dx over one threshold of travel maps to full tilt. + s.deg = Math.max(-rotation, Math.min(rotation, (dx / gap) * rotation)); + items[i].src = images[imageIndex % images.length]; + imageIndex++; + items[i].style.zIndex = String(head); + ensure(); + }; + + const onMove = (e: PointerEvent) => { + // Live rect — the page may have scrolled since the last event. + const r = wrap.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!primed) { + primed = true; + lastX = x; + lastY = y; + acc = 0; + return; + } + const dx = x - lastX; + const dy = y - lastY; + const seg = Math.hypot(dx, dy); + if (seg > 0) { + const banked = acc; // travelled before this segment began + acc += seg; + // Drop at exact multiples of `threshold` measured along the path, and + // carry the remainder. Resetting the accumulator to the event position + // instead would stretch spacing to a whole multiple of the per-event + // travel, so a fast flick would space images further apart than a slow + // drag — the speed dependence this whole approach exists to remove. + const drops = Math.min(items.length, Math.floor(acc / gap)); + let along = gap - banked; + for (let n = 0; n < drops; n++) { + const t = Math.max(0, Math.min(1, along / seg)); + emit(lastX + dx * t, lastY + dy * t, dx); + along += threshold; + } + acc %= gap; + } + lastX = x; + lastY = y; + }; + const onEnter = (e: PointerEvent) => { + const r = wrap.getBoundingClientRect(); + lastX = e.clientX - r.left; + lastY = e.clientY - r.top; + acc = 0; + primed = true; + }; + const onLeave = () => { + primed = false; + }; + + wrap.addEventListener("pointerenter", onEnter); + wrap.addEventListener("pointermove", onMove, { passive: true }); + wrap.addEventListener("pointerleave", onLeave); + + return () => { + cancelAnimationFrame(raf); + wrap.removeEventListener("pointerenter", onEnter); + wrap.removeEventListener("pointermove", onMove); + wrap.removeEventListener("pointerleave", onLeave); + for (const el of items) el.style.opacity = "0"; + }; + }, [fine, reduced, images, count, threshold, duration, rotation]); + + return ( +
+ {children} +
+ {Array.from({ length: count }).map((_, i) => ( + /* eslint-disable-next-line @next/next/no-img-element */ + { + itemRefs.current[i] = el; + }} + alt="" + aria-hidden + draggable={false} + decoding="async" + className="absolute left-0 top-0 select-none rounded-lg object-cover will-change-transform" + style={{ width, height: "auto", opacity: 0 }} + /> + ))} +
+
+ ); +} diff --git a/registry/reactomega/ui/inertia-cursor.tsx b/registry/reactomega/ui/inertia-cursor.tsx new file mode 100644 index 0000000..f820be7 --- /dev/null +++ b/registry/reactomega/ui/inertia-cursor.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface InertiaCursorProps { + className?: string; + /** Diameter of the precise inner dot in px. @default 7 */ + size?: number; + /** Diameter of the lagging outer ring in px. @default 34 */ + ringSize?: number; + /** Ring spring acceleration, 0..1 — higher catches up faster. @default 0.18 */ + stiffness?: number; + /** Ring spring damping, 0..1 — lower kills the bounce harder. @default 0.62 */ + damping?: number; + /** Ring scale while hovering an interactive target. @default 1.7 */ + hoverScale?: number; + /** Selector the ring reacts to, tested with closest() on whatever is hovered. @default "a, button, [data-cursor]" */ + selector?: string; + /** Cursor color. Painted with mix-blend-mode difference, so white inverts whatever sits behind it. @default "#ffffff" */ + color?: string; + /** Hide the native cursor while this one is on screen. @default true */ + hideNative?: boolean; +} + +/** + * InertiaCursor — a two-part cursor replacement: a small dot pinned exactly to + * the pointer, and a larger ring that trails behind it on a spring, so fast + * flicks stretch the pair apart and a stop lets the ring coast in. + * + * Hover state is delegated from `document` via pointerover, so the ring reacts + * to elements that were added to the DOM long after mount, and a scroll listener + * re-tests the node under the cursor for the case where the page moves but the + * mouse does not. Both halves are painted with `mix-blend-mode: difference`, + * which keeps them readable over any background without knowing anything about + * it. Positions are resolved against the overlay's live rect each frame so the + * cursor stays true even if an ancestor transform captures the fixed container. + * + * The rAF loop idles as soon as the ring and the hover spring have settled and + * restarts on the next pointer event. Coarse pointers render nothing at all — + * a lagging ring on a touchscreen is just a stuck artifact. Reduced motion drops + * the spring and the scale: the ring simply follows. + */ +export function InertiaCursor({ + className, + size = 7, + ringSize = 34, + stiffness = 0.18, + damping = 0.62, + hoverScale = 1.7, + selector = "a, button, [data-cursor]", + color = "#ffffff", + hideNative = true, +}: InertiaCursorProps) { + const rootRef = useRef(null); + const dotRef = useRef(null); + const ringRef = useRef(null); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + + // Only mouse-like pointers get a cursor replacement. + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + // Swap out the native cursor for ours, and put it back exactly as we found it. + useEffect(() => { + if (!fine || !hideNative) return; + const style = document.createElement("style"); + style.textContent = ".ro-cursor-none, .ro-cursor-none * { cursor: none !important; }"; + document.head.appendChild(style); + document.documentElement.classList.add("ro-cursor-none"); + return () => { + document.documentElement.classList.remove("ro-cursor-none"); + style.remove(); + }; + }, [fine, hideNative]); + + useEffect(() => { + if (!fine) return; + const root = rootRef.current; + const dot = dotRef.current; + const ring = ringRef.current; + if (!root || !dot || !ring) return; + + // An invalid selector would throw on every single pointerover, so test it once. + let safeSelector = selector; + try { + document.querySelector(selector); + } catch { + safeSelector = ""; + } + + // Viewport coords of the pointer; ring position and hover amount are sprung. + let px = -9999; + let py = -9999; + let rx = -9999; + let ry = -9999; + let vx = 0; + let vy = 0; + let hover = 0; + let hoverV = 0; + let hoverTarget = 0; + let seen = false; + let raf = 0; + let running = false; + + const step = () => { + // Live rect: if an ancestor transform has captured our fixed container, + // this keeps the cursor glued to the real pointer anyway. + const r = root.getBoundingClientRect(); + + if (reduced) { + rx = px; + ry = py; + vx = 0; + vy = 0; + hover = hoverTarget; + hoverV = 0; + } else { + vx = (vx + (px - rx) * stiffness) * damping; + vy = (vy + (py - ry) * stiffness) * damping; + rx += vx; + ry += vy; + hoverV = (hoverV + (hoverTarget - hover) * stiffness) * damping; + hover += hoverV; + } + + const scale = 1 + (hoverScale - 1) * hover; + dot.style.transform = `translate3d(${(px - r.left).toFixed(2)}px, ${(py - r.top).toFixed(2)}px, 0) translate(-50%, -50%)`; + ring.style.transform = `translate3d(${(rx - r.left).toFixed(2)}px, ${(ry - r.top).toFixed(2)}px, 0) translate(-50%, -50%) scale(${scale.toFixed(3)})`; + ring.style.opacity = (0.55 + 0.45 * Math.max(0, Math.min(1, hover))).toFixed(3); + + const moving = + Math.abs(px - rx) > 0.05 || + Math.abs(py - ry) > 0.05 || + Math.abs(vx) > 0.05 || + Math.abs(vy) > 0.05 || + Math.abs(hoverTarget - hover) > 0.002 || + Math.abs(hoverV) > 0.002; + + if (moving) raf = requestAnimationFrame(step); + else running = false; + }; + + const ensure = () => { + if (!running) { + running = true; + raf = requestAnimationFrame(step); + } + }; + + const testHover = (node: EventTarget | Element | null) => { + if (!safeSelector) return; + hoverTarget = node instanceof Element && node.closest(safeSelector) ? 1 : 0; + }; + + const onMove = (e: PointerEvent) => { + px = e.clientX; + py = e.clientY; + if (!seen) { + // First sighting: drop the ring on the pointer instead of flying it in + // from the corner, then fade the pair up. + seen = true; + rx = px; + ry = py; + root.style.opacity = "1"; + } + ensure(); + }; + const onOver = (e: PointerEvent) => { + testHover(e.target); + ensure(); + }; + const onScroll = () => { + if (!seen) return; + testHover(document.elementFromPoint(px, py)); + ensure(); + }; + const onLeave = (e: PointerEvent) => { + if (e.relatedTarget) return; + root.style.opacity = "0"; + seen = false; + hoverTarget = 0; + ensure(); + }; + const onBlur = () => { + root.style.opacity = "0"; + seen = false; + }; + + document.addEventListener("pointermove", onMove, { passive: true }); + document.addEventListener("pointerover", onOver, { passive: true }); + document.addEventListener("pointerout", onLeave, { passive: true }); + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("blur", onBlur); + ensure(); + + return () => { + cancelAnimationFrame(raf); + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerover", onOver); + document.removeEventListener("pointerout", onLeave); + window.removeEventListener("scroll", onScroll); + window.removeEventListener("blur", onBlur); + }; + }, [fine, reduced, stiffness, damping, hoverScale, selector]); + + if (!fine) return null; + + return ( +
+
+
+
+ ); +} diff --git a/registry/reactomega/ui/liquid-metal.tsx b/registry/reactomega/ui/liquid-metal.tsx new file mode 100644 index 0000000..b7376d2 --- /dev/null +++ b/registry/reactomega/ui/liquid-metal.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface LiquidMetalProps { + className?: string; + /** Highlight tint of the polished surface. @default "#c9d4ff" */ + tint?: string; + /** Colour in the troughs. @default "#0a0b14" */ + shadow?: string; + /** Zoom of the flow pattern — higher is busier. @default 1.5 */ + scale?: number; + /** Flow speed multiplier. @default 1 */ + speed?: number; + /** Number of polish bands swept across the surface. @default 5 */ + bands?: number; + /** How hard the pointer pushes into the metal, 0..1. @default 0.6 */ + push?: number; +} + +/** + * LiquidMetal — a molten chrome surface that flows, catches light and dents + * away from the pointer. + * + * The height field is two rounds of domain-warped value-noise FBM; the surface + * normal is taken from finite differences of that field each frame, then lit + * with a diffuse term, a tight specular, and a swept band pattern derived from + * the normal — which is what reads as *polished* rather than merely bumpy. + * Runs on raw WebGL2 with no dependencies, and settles into a single still + * frame for reduced-motion users. + */ +export function LiquidMetal({ + className, + tint = "#c9d4ff", + shadow = "#0a0b14", + scale = 1.5, + speed = 1, + bands = 5, + push = 0.6, +}: LiquidMetalProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uShadow: hexToRgb(shadow), + uScale: scale, + uBands: bands, + uPush: push, + }), + [tint, shadow, scale, bands, push], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float a = 0.5; + float v = 0.0; + for (int i = 0; i < 4; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +// Two rounds of domain warping — the second warp is what gives the metal its +// stretched, poured look instead of generic cloud noise. +float field(vec2 p, float t) { + vec2 q = vec2(fbm(p + vec2(0.0, t * 0.20)), fbm(p + vec2(5.2, 1.3))); + vec2 r = vec2(fbm(p + 2.1 * q + vec2(1.7, 9.2) - t * 0.15), + fbm(p + 2.1 * q + vec2(8.3, 2.8) + t * 0.12)); + return fbm(p + 1.7 * r); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + // Pointer dents the surface: push the sample point radially away from it. + vec2 away = uv - pc; + float d2 = dot(away, away); + float dent = uPointer.z * uPush * exp(-d2 * 7.0); + vec2 p = uv * uScale + normalize(away + 1e-5) * dent; + + float t = uTime * 0.4; + // Wide finite-difference stencil on purpose: sampling the noise too tightly + // returns per-pixel normals, which read as grey static rather than poured metal. + float e = 0.030; + float h = field(p, t); + vec3 n = normalize(vec3(field(p + vec2(e, 0.0), t) - h, field(p + vec2(0.0, e), t) - h, 0.09)); + + vec3 L = normalize(vec3(0.45, 0.72, 0.62)); + float diff = max(dot(n, L), 0.0); + float spec = pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 42.0); + + // Swept polish bands read off the normal, not the height — this is the + // difference between "chrome" and "grey clouds". + float sweep = 0.5 + 0.5 * sin(n.x * uBands * 1.1 + n.y * uBands * 0.8 + h * uBands * 0.7 - t * 0.9); + vec3 base = mix(uShadow, uTint, smoothstep(0.10, 0.95, sweep)); + + vec3 col = base * (0.30 + 0.90 * diff) + spec * 1.35; + col += (uPointer.z * 0.10) * exp(-d2 * 9.0) * uTint; + + // Dither out 8-bit banding in the dark falloff. + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + col *= 1.0 - 0.55 * dot(uv, uv); + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/parallax-layers.tsx b/registry/reactomega/ui/parallax-layers.tsx new file mode 100644 index 0000000..9ec4627 --- /dev/null +++ b/registry/reactomega/ui/parallax-layers.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ParallaxLayersProps { + children: React.ReactNode; + /** Extra classes for the section. @default undefined */ + className?: string; + /** Drift of a `depth={1}` layer at the extremes of the pass, in px. @default 120 */ + range?: number; + /** Axis the layers drift along. @default "y" */ + axis?: "y" | "x"; + /** Hold the drift at its extremes once the section has left the viewport. @default true */ + clamp?: boolean; +} + +export interface ParallaxLayerProps { + children: React.ReactNode; + /** Extra classes for the layer. @default undefined */ + className?: string; + /** 0 is locked to the page, 1 drifts the full `range`. Values above 1 are allowed. @default 0.3 */ + depth?: number; + /** Extra scale at `depth={1}`, e.g. 0.08 to over-size drifting layers so no edge is exposed. @default 0 */ + scale?: number; + /** Blur at `depth={1}`, in px, scaled by depth. 0 disables the filter entirely. @default 0 */ + blur?: number; +} + +interface Registration { + el: HTMLElement; + depth: number; + scale: number; + blur: number; +} + +const ParallaxContext = createContext<{ register: (r: Registration) => () => void } | null>(null); + +/** + * ParallaxLayers — a section whose children drift at different rates as it passes through + * the viewport, so nearer things outrun farther ones and the band reads as having depth. + * + * Progress is signed and derived from the container's own position rather than raw + * `scrollY`: `(viewportCentre - sectionCentre) / ((viewportHeight + sectionHeight) / 2)`. + * That is exactly -1 when the section is about to enter, **0 when the section is centred**, + * and +1 when it has just left — so the layers sit at their neutral, as-authored positions + * at the moment the reader is actually looking at them, and the effect never depends on + * where the section happens to live in the document. + * + * Layers register their DOM node with the container through context rather than receiving + * progress as a prop, so the scroll loop writes `transform` straight to each node and the + * React tree never re-renders while scrolling. The container reads one rect per frame, + * coalesced from a passive scroll listener via a dirty animation frame, and re-measures + * through a ResizeObserver. + * + * Reduced-motion users get every layer at its neutral position in normal flow — nothing is + * displaced, blurred or scaled. + */ +export function ParallaxLayers({ + children, + className, + range = 120, + axis = "y", + clamp = true, +}: ParallaxLayersProps) { + const ref = useRef(null); + const registry = useRef>(new Set()); + const requestRef = useRef<() => void>(() => {}); + const reduced = usePrefersReducedMotion(); + + const register = useCallback((entry: Registration) => { + registry.current.add(entry); + requestRef.current(); + return () => { + registry.current.delete(entry); + entry.el.style.transform = ""; + entry.el.style.filter = ""; + }; + }, []); + + const ctx = useMemo(() => ({ register }), [register]); + + useEffect(() => { + const clear = () => { + registry.current.forEach((entry) => { + entry.el.style.transform = ""; + entry.el.style.filter = ""; + }); + }; + + if (reduced) { + clear(); + return; + } + + const el = ref.current; + if (!el) return; + + let raf = 0; + + const update = () => { + raf = 0; + const rect = el.getBoundingClientRect(); + const vh = window.innerHeight || 1; + const span = (vh + rect.height) / 2; + if (span <= 0) return; + + let p = (vh / 2 - (rect.top + rect.height / 2)) / span; + if (clamp) p = p < -1 ? -1 : p > 1 ? 1 : p; + + registry.current.forEach((entry) => { + const shift = p * range * entry.depth; + const move = + axis === "x" + ? `translate3d(${shift.toFixed(2)}px, 0, 0)` + : `translate3d(0, ${shift.toFixed(2)}px, 0)`; + const s = 1 + entry.depth * entry.scale; + entry.el.style.transform = entry.scale !== 0 ? `${move} scale(${s.toFixed(4)})` : move; + if (entry.blur > 0) entry.el.style.filter = `blur(${(entry.depth * entry.blur).toFixed(2)}px)`; + }); + }; + + const request = () => { + if (!raf) raf = requestAnimationFrame(update); + }; + + requestRef.current = request; + update(); + window.addEventListener("scroll", request, { passive: true }); + window.addEventListener("resize", request); + const ro = new ResizeObserver(request); + ro.observe(el); + + return () => { + cancelAnimationFrame(raf); + requestRef.current = () => {}; + window.removeEventListener("scroll", request); + window.removeEventListener("resize", request); + ro.disconnect(); + clear(); + }; + }, [reduced, range, axis, clamp]); + + return ( + +
+ {children} +
+
+ ); +} + +/** + * ParallaxLayer — one plane inside a `ParallaxLayers` section. It registers its node with + * the parent on mount and is driven imperatively from there, so changing `depth` re-registers + * and takes effect on the next frame. Used outside a `ParallaxLayers` it renders as a plain + * block with no motion, which is also exactly what reduced-motion users get. + */ +export function ParallaxLayer({ children, className, depth = 0.3, scale = 0, blur = 0 }: ParallaxLayerProps) { + const ref = useRef(null); + const ctx = useContext(ParallaxContext); + + useEffect(() => { + const el = ref.current; + if (!el || !ctx) return; + return ctx.register({ el, depth, scale, blur }); + }, [ctx, depth, scale, blur]); + + return ( +
+ {children} +
+ ); +} diff --git a/registry/reactomega/ui/pixel-trail.tsx b/registry/reactomega/ui/pixel-trail.tsx new file mode 100644 index 0000000..8e17dd7 --- /dev/null +++ b/registry/reactomega/ui/pixel-trail.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface PixelTrailProps { + className?: string; + /** Edge length of one cell in px. @default 12 */ + pixelSize?: number; + /** Lit pixel color. @default "#7c5cff" */ + color?: string; + /** Brightness kept per frame, 0..1 — higher leaves a longer comet. @default 0.9 */ + decay?: number; + /** Bloom strength around lit pixels; 0 disables it. @default 0.6 */ + glow?: number; + /** Space between cells in px. @default 2 */ + gap?: number; +} + +/** + * PixelTrail — a grid of square cells that ignite to full brightness as the + * pointer crosses them and then decay exponentially, leaving a dissolving comet + * of pixels behind the cursor. + * + * Cells are lit by walking the segment between the previous and current pointer + * position at half-cell steps rather than by lighting whatever sits under the + * latest event. A mouse reporting at 60Hz can travel hundreds of px between two + * events, and per-event lighting turns a fast flick into a dotted line; segment + * interpolation keeps the path continuous at any speed, with the sample count + * capped so a window-wide jump cannot spike a frame. + * + * Brightness lives in a single Float32Array multiplied by `decay` each frame — + * at the default that is a ~110ms half life. Only cells above the visible + * threshold are painted, in two passes (an additive bloom pass, then the crisp + * cores) so the composite mode is set twice per frame instead of twice per pixel. + * The loop stops the moment the grid is dark and restarts on the next move. + * Reduced motion and coarse pointers get the same grid rendered once, unlit and + * static, so the surface still reads as a textured panel. + */ +export function PixelTrail({ + className, + pixelSize = 12, + color = "#7c5cff", + decay = 0.9, + glow = 0.6, + gap = 2, +}: PixelTrailProps) { + const ref = useRef(null); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const cell = Math.max(1, pixelSize); + const step = cell + Math.max(0, gap); + const fade = Math.min(0.985, Math.max(0.5, decay)); + const still = reduced || !fine; + + let w = 0; + let h = 0; + let cols = 0; + let rows = 0; + let buf = new Float32Array(0); + let raf = 0; + let running = false; + let lastX = -1; + let lastY = -1; + + const paintStatic = () => { + ctx.clearRect(0, 0, w, h); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.1; + ctx.fillStyle = color; + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) ctx.fillRect(x * step, y * step, cell, cell); + } + ctx.globalAlpha = 1; + }; + + const resize = () => { + const r = canvas.getBoundingClientRect(); + w = Math.max(1, r.width); + h = Math.max(1, r.height); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + cols = Math.max(1, Math.ceil(w / step)); + rows = Math.max(1, Math.ceil(h / step)); + buf = new Float32Array(cols * rows); + if (still) paintStatic(); + }; + + const draw = () => { + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = color; + + // Pass 1 — additive bloom, laid down under the cores. + if (glow > 0) { + ctx.globalCompositeOperation = "lighter"; + const pad = cell * 0.55; + for (let i = 0; i < buf.length; i++) { + const v = buf[i]; + if (v < 0.02) continue; + ctx.globalAlpha = Math.min(1, v * 0.3 * glow); + ctx.fillRect((i % cols) * step - pad, Math.floor(i / cols) * step - pad, cell + pad * 2, cell + pad * 2); + } + } + + // Pass 2 — crisp cores, plus the decay sweep for every cell. + ctx.globalCompositeOperation = "source-over"; + let lit = 0; + for (let i = 0; i < buf.length; i++) { + const v = buf[i]; + if (v < 0.02) { + if (v !== 0) buf[i] = 0; + continue; + } + lit++; + ctx.globalAlpha = Math.min(1, v); + ctx.fillRect((i % cols) * step, Math.floor(i / cols) * step, cell, cell); + buf[i] = v * fade; + } + + ctx.globalAlpha = 1; + if (lit > 0) raf = requestAnimationFrame(draw); + else running = false; + }; + + const ensure = () => { + if (still || running) return; + running = true; + raf = requestAnimationFrame(draw); + }; + + const ignite = (x0: number, y0: number, x1: number, y1: number) => { + const dist = Math.hypot(x1 - x0, y1 - y0); + const samples = Math.min(240, Math.max(1, Math.ceil(dist / (step * 0.5)))); + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const cx = Math.floor((x0 + (x1 - x0) * t) / step); + const cy = Math.floor((y0 + (y1 - y0) * t) / step); + if (cx < 0 || cy < 0 || cx >= cols || cy >= rows) continue; + buf[cy * cols + cx] = 1; + } + }; + + const onMove = (e: PointerEvent) => { + if (still) return; + const r = canvas.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (lastX < 0) ignite(x, y, x, y); + else ignite(lastX, lastY, x, y); + lastX = x; + lastY = y; + ensure(); + }; + // Break the path on both enter and leave so re-entering the panel does not + // draw a streak across it from wherever the pointer left. + const breakPath = () => { + lastX = -1; + lastY = -1; + }; + + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas); + canvas.addEventListener("pointermove", onMove, { passive: true }); + canvas.addEventListener("pointerenter", breakPath); + canvas.addEventListener("pointerleave", breakPath); + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + canvas.removeEventListener("pointermove", onMove); + canvas.removeEventListener("pointerenter", breakPath); + canvas.removeEventListener("pointerleave", breakPath); + }; + }, [pixelSize, color, decay, glow, gap, reduced, fine]); + + return ; +} diff --git a/registry/reactomega/ui/scroll-scene.tsx b/registry/reactomega/ui/scroll-scene.tsx new file mode 100644 index 0000000..cfc9563 --- /dev/null +++ b/registry/reactomega/ui/scroll-scene.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ScrollSceneProps { + /** Render prop. Receives the scrub progress: 0 the moment the scene pins, 1 when it unpins. */ + children: (progress: number) => React.ReactNode; + /** Extra classes for the pinned viewport — the visible surface, not the tall outer spacer. @default undefined */ + className?: string; + /** Extra viewports of scroll to pin for. 1 means one full screen of scrubbing. @default 1.5 */ + length?: number; + /** Progress handed to children when the user prefers reduced motion. @default 1 */ + staticProgress?: number; + /** Inertia on the scrub, 0 to 1. 0 tracks scroll exactly; higher glides in behind it. @default 0.72 */ + smoothing?: number; + /** Called every frame with the raw, unrounded progress. The zero-re-render escape hatch. @default undefined */ + onProgress?: (progress: number) => void; +} + +/** + * ScrollScene — pins a section for a set distance and hands its children the scrub + * progress, so anything at all can be driven by scroll position. + * + * A tall outer spacer (`100vh * (1 + length)`) holds the scroll distance; inside it a + * `position: sticky; top: 0` viewport does the pinning natively, which is smoother than + * a JS-driven fixed element and does not fight iOS momentum scrolling. Progress is + * `-outer.top / (outer.height - pinned.height)` — deriving the travel from the two + * measured heights rather than assuming `innerHeight` is what keeps it exact, because the + * pin genuinely ends when the sticky element's bottom meets the spacer's bottom, and mobile + * `100vh` is not the visible viewport. Off-by-one-viewport is the classic bug here and this + * form has no viewport constant in it at all. + * + * Three ways out, in increasing cost: the pinned element carries a live `--ro-progress` + * custom property (free, pure CSS); `onProgress` fires with the raw value every frame (no + * re-render); and the render prop re-renders, but only on a change of 1/1000, so a frame + * that does not move the value does not cost a render. Smoothing is a frame-rate-independent + * exponential (`1 - smoothing^(dt*60)`), and the loop stops itself once it has settled. + * + * Reduced motion removes the spacer and the pin entirely: the section takes its natural + * height in normal flow and the children are rendered once at `staticProgress`, so nothing + * is ever left off-screen or invisible. Keep focusable elements visible across the whole + * range — a link that only exists at progress 0.5 is a keyboard trap. + */ +export function ScrollScene({ + children, + className, + length = 1.5, + staticProgress = 1, + smoothing = 0.72, + onProgress, +}: ScrollSceneProps) { + const outerRef = useRef(null); + const pinRef = useRef(null); + const callbackRef = useRef(onProgress); + const [progress, setProgress] = useState(0); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + callbackRef.current = onProgress; + }); + + useEffect(() => { + if (!reduced) return; + pinRef.current?.style.setProperty("--ro-progress", staticProgress.toFixed(5)); + callbackRef.current?.(staticProgress); + }, [reduced, staticProgress]); + + useEffect(() => { + if (reduced) return; + const outer = outerRef.current; + const pin = pinRef.current; + if (!outer || !pin) return; + + const inertia = smoothing < 0 ? 0 : smoothing > 0.99 ? 0.99 : smoothing; + let raf = 0; + let running = false; + let dirty = false; + let primed = false; + let last = 0; + let current = 0; + let target = 0; + + const measure = () => { + const outerRect = outer.getBoundingClientRect(); + const travel = outerRect.height - pin.getBoundingClientRect().height; + if (travel <= 0) { + target = outerRect.top <= 0 ? 1 : 0; + return; + } + const p = -outerRect.top / travel; + target = p < 0 ? 0 : p > 1 ? 1 : p; + }; + + const commit = (value: number) => { + pin.style.setProperty("--ro-progress", value.toFixed(5)); + callbackRef.current?.(value); + const quantised = Math.round(value * 1000) / 1000; + setProgress((prev) => (prev === quantised ? prev : quantised)); + }; + + const step = (now: number) => { + // Layout reads happen here, never in the scroll handler. + if (dirty) { + dirty = false; + measure(); + } + const dt = primed ? Math.min(0.05, (now - last) / 1000) : 1 / 60; + last = now; + primed = true; + + const k = inertia > 0 ? 1 - Math.pow(inertia, dt * 60) : 1; + current += (target - current) * k; + if (Math.abs(target - current) < 0.0002) current = target; + commit(current); + + if (current !== target || dirty) { + raf = requestAnimationFrame(step); + } else { + running = false; + raf = 0; + } + }; + + const ensure = () => { + if (running) return; + running = true; + primed = false; + raf = requestAnimationFrame(step); + }; + + const invalidate = () => { + dirty = true; + ensure(); + }; + + measure(); + current = target; + commit(current); + + window.addEventListener("scroll", invalidate, { passive: true }); + window.addEventListener("resize", invalidate); + const ro = new ResizeObserver(invalidate); + ro.observe(outer); + ro.observe(pin); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("scroll", invalidate); + window.removeEventListener("resize", invalidate); + ro.disconnect(); + }; + }, [reduced, smoothing]); + + const value = reduced ? staticProgress : progress; + + return ( +
+
+ {children(value)} +
+ {!reduced && ( + + )} +
+ ); +} diff --git a/registry/reactomega/ui/scroll-stack.tsx b/registry/reactomega/ui/scroll-stack.tsx new file mode 100644 index 0000000..3bf1ce4 --- /dev/null +++ b/registry/reactomega/ui/scroll-stack.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Children, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ScrollStackProps { + children: React.ReactNode; + /** Extra classes for the stack container. @default undefined */ + className?: string; + /** Distance from the top of the viewport that each card pins at, in px. @default 80 */ + offset?: number; + /** Scale removed per card resting on top of this one. @default 0.04 */ + scaleStep?: number; + /** Opacity removed per card resting on top of this one. @default 0.14 */ + opacityStep?: number; + /** Blur added per card resting on top of this one, in px. 0 disables the filter entirely. @default 2 */ + blur?: number; + /** Vertical space between cards while they are still in flow, in px. @default 64 */ + gap?: number; +} + +/** Cards deeper than this stop receding, so a long list can never invert the scale. */ +const MAX_DEPTH = 4; + +const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n); + +/** + * ScrollStack — cards that collect into a deck as you scroll. Every card is pinned + * with native `position: sticky` (never JS-driven fixed positioning, so it survives + * iOS momentum scrolling), and each one recedes by the number of cards already + * resting on top of it. + * + * The depth of a card is the running sum of how far every later card has travelled + * to its rest position, measured live from the sticky wrappers' own rects — so it is + * fractional and continuous rather than stepped. A receding card scales from its top + * edge and lifts by exactly the height it loses (`height * depth * scaleStep`), which + * pushes its top edge above the card in front by that same amount and produces the + * fanned deck without any magic peek constant. The last card has nothing on top of it, + * so it always renders clean. + * + * The transform lives on an inner element and the rect is read from the sticky wrapper, + * so the measurement can never be contaminated by the transform it produces. Reads for + * every card happen in one pass before any write, and scroll events only raise a dirty + * flag that is drained on the next animation frame. + * + * Give each child an opaque background — they overlap by design. Reduced-motion users + * get the same cards unpinned, unscaled and in normal document flow. + */ +export function ScrollStack({ + children, + className, + offset = 80, + scaleStep = 0.04, + opacityStep = 0.14, + blur = 2, + gap = 64, +}: ScrollStackProps) { + const containerRef = useRef(null); + const wrapRefs = useRef<(HTMLDivElement | null)[]>([]); + const innerRefs = useRef<(HTMLDivElement | null)[]>([]); + const reduced = usePrefersReducedMotion(); + const items = Children.toArray(children); + + useEffect(() => { + const inners = innerRefs.current; + const reset = () => { + inners.forEach((el) => { + if (!el) return; + el.style.transform = ""; + el.style.opacity = ""; + el.style.filter = ""; + }); + }; + + if (reduced) { + reset(); + return; + } + + const container = containerRef.current; + if (!container) return; + + let raf = 0; + + const update = () => { + raf = 0; + const wraps = wrapRefs.current; + const n = wraps.length; + if (!n) return; + + // Read pass — every rect first, so no write can force an extra layout. + const rects: (DOMRect | null)[] = new Array(n); + for (let i = 0; i < n; i++) { + const w = wraps[i]; + rects[i] = w ? w.getBoundingClientRect() : null; + } + + // How far each card has travelled from "sitting below its predecessor" to + // "landed on the stack". 0 when its top is one gap below the previous card's + // bottom, 1 when the two tops coincide at the pin line. + const arrival: number[] = new Array(n).fill(0); + for (let j = 1; j < n; j++) { + const prev = rects[j - 1]; + const cur = rects[j]; + if (!prev || !cur) continue; + const travel = prev.height + gap; + if (travel <= 0) continue; + arrival[j] = clamp01((prev.bottom + gap - cur.top) / travel); + } + + // Write pass — depth of card i is the sum of every arrival after it. + let acc = 0; + for (let i = n - 1; i >= 0; i--) { + const inner = inners[i]; + const rect = rects[i]; + const depth = acc < MAX_DEPTH ? acc : MAX_DEPTH; + acc += arrival[i]; + if (!inner) continue; + + if (depth < 0.001) { + inner.style.transform = ""; + inner.style.opacity = ""; + if (blur > 0) inner.style.filter = ""; + continue; + } + + const scale = 1 - depth * scaleStep; + const lift = depth * (rect ? rect.height : 0) * scaleStep; + inner.style.transform = `translate3d(0, ${(-lift).toFixed(2)}px, 0) scale(${scale.toFixed(4)})`; + inner.style.opacity = Math.max(0, 1 - depth * opacityStep).toFixed(3); + if (blur > 0) inner.style.filter = `blur(${(depth * blur).toFixed(2)}px)`; + } + }; + + const request = () => { + if (!raf) raf = requestAnimationFrame(update); + }; + + update(); + window.addEventListener("scroll", request, { passive: true }); + window.addEventListener("resize", request); + const ro = new ResizeObserver(request); + ro.observe(container); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("scroll", request); + window.removeEventListener("resize", request); + ro.disconnect(); + reset(); + }; + }, [reduced, offset, scaleStep, opacityStep, blur, gap, items.length]); + + return ( +
+ {items.map((child, i) => ( +
{ + wrapRefs.current[i] = el; + }} + className={reduced ? "relative" : "sticky"} + style={reduced ? undefined : { top: `${offset}px` }} + > +
{ + innerRefs.current[i] = el; + }} + className={cn("origin-top", !reduced && "will-change-transform")} + > + {child} +
+
+ ))} +
+ ); +} diff --git a/registry/reactomega/ui/scroll-velocity.tsx b/registry/reactomega/ui/scroll-velocity.tsx new file mode 100644 index 0000000..0a8c147 --- /dev/null +++ b/registry/reactomega/ui/scroll-velocity.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ScrollVelocityProps { + children: React.ReactNode; + /** Extra classes for the clipping row. @default undefined */ + className?: string; + /** Baseline drift in px per second, running even when the page is still. @default 40 */ + baseSpeed?: number; + /** Fraction of the scroll velocity (px/s) added to the drift. @default 0.75 */ + velocityFactor?: number; + /** Degrees of skew per 1000 px/s of scroll velocity. @default 5 */ + skew?: number; + /** Hard cap on the skew, in degrees. @default 12 */ + maxSkew?: number; + /** Direction of the baseline drift. @default "left" */ + direction?: "left" | "right"; +} + +/** Space between repeats, in px. Also the seam gap, so the loop period is width + this. */ +const SEAM = 32; +/** Scroll velocity is clamped here (px/s) so a flick or a scroll-to-top cannot slingshot the row. */ +const MAX_VELOCITY = 2600; + +/** + * ScrollVelocity — a row of repeated content that always drifts at a baseline speed but + * is shoved along by how fast you are scrolling, skewing into the lurch and easing back + * to its baseline when you stop. + * + * Velocity is the change in `scrollY` divided by the real frame delta, then smoothed with + * a time-constant exponential filter (`1 - e^(-dt/tau)`) rather than a fixed per-frame + * lerp, so the feel is identical at 60Hz and 120Hz. It is clamped before use, which is + * what stops a scroll-to-top or a trackpad flick from launching the row across the screen. + * The smoothing is what makes it *ease* back rather than snap: when scrolling stops the + * instantaneous velocity is 0 and the filter decays into it. + * + * The loop is seamless in the marquee way — the track is duplicated and the offset is + * wrapped with a modulo of one repeat's period (measured width plus the seam gap, taken + * from a ResizeObserver so the transform on the row can never contaminate it). Because the + * offset is normalised into [0, period) every frame, scroll velocity may reverse the row + * past its baseline direction without ever exposing an edge. + * + * The animation frame only runs while the row is on screen, and reduced-motion users get a + * single static, wrapped copy — all content readable, nothing clipped or in motion. + */ +export function ScrollVelocity({ + children, + className, + baseSpeed = 40, + velocityFactor = 0.75, + skew = 5, + maxSkew = 12, + direction = "left", +}: ScrollVelocityProps) { + const rootRef = useRef(null); + const rowRef = useRef(null); + const copyRef = useRef(null); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + if (reduced) return; + const root = rootRef.current; + const row = rowRef.current; + const copy = copyRef.current; + if (!root || !row || !copy) return; + + const dir = direction === "right" ? -1 : 1; + let period = 0; + let x = 0; + let velocity = 0; + let last = 0; + let lastY = window.scrollY; + let raf = 0; + + const step = (now: number) => { + // Clamped so a background tab or a long frame cannot teleport the row. + const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60; + last = now; + + const y = window.scrollY; + const instant = dt > 0 ? (y - lastY) / dt : 0; + lastY = y; + + const k = 1 - Math.exp(-dt / 0.09); + velocity += (instant - velocity) * k; + if (velocity > MAX_VELOCITY) velocity = MAX_VELOCITY; + else if (velocity < -MAX_VELOCITY) velocity = -MAX_VELOCITY; + + x += (baseSpeed * dir + velocity * velocityFactor) * dt; + if (period > 0) x = ((x % period) + period) % period; + + let deg = (velocity / 1000) * skew; + if (deg > maxSkew) deg = maxSkew; + else if (deg < -maxSkew) deg = -maxSkew; + + row.style.transform = `translate3d(${(-x).toFixed(2)}px, 0, 0) skewX(${deg.toFixed(2)}deg)`; + raf = requestAnimationFrame(step); + }; + + const start = () => { + if (raf) return; + // Re-prime the clock and the scroll anchor, otherwise resuming produces one + // enormous fake velocity spike from everything that happened while paused. + last = 0; + lastY = window.scrollY; + raf = requestAnimationFrame(step); + }; + const stop = () => { + if (!raf) return; + cancelAnimationFrame(raf); + raf = 0; + }; + + const ro = new ResizeObserver((entries) => { + period = entries[0].contentRect.width + SEAM; + }); + ro.observe(copy); + + const io = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) start(); + else stop(); + }, + { rootMargin: "160px 0px" }, + ); + io.observe(root); + + return () => { + stop(); + ro.disconnect(); + io.disconnect(); + row.style.transform = ""; + }; + }, [reduced, baseSpeed, velocityFactor, skew, maxSkew, direction]); + + if (reduced) { + return ( +
+
+ {children} +
+
+ ); + } + + return ( +
+
+ {[0, 1].map((k) => ( +
+ {children} +
+ ))} +
+
+ ); +} diff --git a/registry/reactomega/ui/thin-film.tsx b/registry/reactomega/ui/thin-film.tsx new file mode 100644 index 0000000..83ad164 --- /dev/null +++ b/registry/reactomega/ui/thin-film.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface ThinFilmProps { + className?: string; + /** Mean film thickness in nanometres — picks which interference order you see. @default 380 */ + thickness?: number; + /** Drift speed of the film. @default 1 */ + speed?: number; + /** 0 collapses the fringes to grey, 1 is the raw interference colour. @default 0.68 */ + saturation?: number; + /** Zoom of the thickness field — higher gives tighter fringes. @default 1.9 */ + scale?: number; + /** Colour the whole film is biased toward. @default "#7b6cff" */ + tint?: string; +} + +/** + * ThinFilm — a soap-film / oil-slick sheen whose colour is computed from actual + * thin-film interference rather than a rotated hue wheel. + * + * A warped FBM (crossed with two slow sine trains so the fringes stay crisp) + * gives a thickness field in nanometres. The surface normal comes from finite + * differences of that field, Snell's law bends the view ray to the internal + * angle, and the reflected intensity at three representative wavelengths — + * 610nm, 550nm, 465nm — is the two-beam result I(λ) = sin²(δ/2) with + * δ = 4π·n·d·cosθ/λ + π, the π accounting for the hard-surface phase flip. + * Because red, green and blue go in and out of phase at different rates, the + * band order you get is the real one: magenta into gold into cyan, and the + * colour genuinely shifts with viewing angle. The pointer squeezes the film + * thinner, which walks every fringe under the cursor. + */ +export function ThinFilm({ + className, + thickness = 380, + speed = 1, + saturation = 0.68, + scale = 1.9, + tint = "#7b6cff", +}: ThinFilmProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uThickness: thickness, + uSat: saturation, + uScale: scale, + }), + [tint, thickness, saturation, scale], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float a = 0.5; + float v = 0.0; + for (int i = 0; i < 4; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +// Normalised film thickness, 0..1. Warped FBM gives the draining-soap blotches; +// the sine train reinstates the crisp fringe edges noise alone smears away. +float filmField(vec2 p, float t) { + vec2 w = vec2(fbm(p + vec2(0.0, t * 0.11)), fbm(p * 1.13 + vec2(4.7, -t * 0.09))); + float n = fbm(p + 2.3 * w); + float ripple = 0.5 + 0.5 * sin(p.x * 2.7 + p.y * 1.9 + t * 0.8 + n * 5.0); + return mix(n, ripple, 0.34); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.5; + vec2 p = uv * uScale; + + // The pointer presses the film thinner. Fringe order is a function of d, so + // thinning here does not brighten the film — it walks the bands sideways. + vec2 rel = uv - pc; + float press = uPointer.z * exp(-dot(rel, rel) * 9.0); + + float e = 0.010; + float h = filmField(p, t); + vec3 n = normalize(vec3((h - filmField(p + vec2(e, 0.0), t)) * 2.2, + (h - filmField(p + vec2(0.0, e), t)) * 2.2, 0.22)); + + // Snell: the optical path inside the film uses the refracted angle, not the + // incident one, which is why the fringes crowd toward grazing incidence. + float ior = 1.42; + float cosI = clamp(n.z, 0.05, 1.0); + float sinT = sqrt(max(0.0, 1.0 - cosI * cosI)) / ior; + float cosT = sqrt(max(0.0, 1.0 - sinT * sinT)); + + // A narrower thickness swing means fewer fringes across the frame, so the + // bands stay broad and pastel instead of stacking into a rainbow moiré. + float d = uThickness * (0.62 + 0.55 * h) * (1.0 - 0.45 * press); + + // Two-beam interference at three representative wavelengths. + const vec3 LAMBDA = vec3(610.0, 550.0, 465.0); + vec3 delta = (12.566370614 * ior * d * cosT) / LAMBDA + 3.14159265; + vec3 film = 0.5 - 0.5 * cos(delta); + + // Knock the green channel back: unweighted, the middle wavelength peaks alone + // and the frame reads as a heat map rather than an oil slick. + film = pow(film, vec3(0.94, 1.18, 1.0)); + float lum = dot(film, vec3(0.2126, 0.7152, 0.0722)); + film = clamp(mix(vec3(lum), film, uSat), 0.0, 1.0); + film *= mix(vec3(1.0), uTint * 1.9 + 0.12, 0.60); + + // Where the film runs thin it reflects almost nothing. That black is what + // stops the whole frame reading as one flat rainbow smear. + float density = smoothstep(0.15, 0.72, h) * (0.58 + 0.42 * pow(1.0 - cosI, 2.0)); + vec3 col = uTint * 0.026 + film * density * 0.88; + + vec3 L = normalize(vec3(0.35, 0.68, 0.64)); + col += pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 48.0) * 0.50; + col += uTint * 0.14 * press; + + col *= 1.0 - 0.42 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/registry/reactomega/ui/volumetric-rays.tsx b/registry/reactomega/ui/volumetric-rays.tsx new file mode 100644 index 0000000..7508322 --- /dev/null +++ b/registry/reactomega/ui/volumetric-rays.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface VolumetricRaysProps { + className?: string; + /** Colour of the light. @default "#b9c6ff" */ + color?: string; + /** Samples along each ray — quality, not brightness. @default 34 */ + rays?: number; + /** Falloff per unit distance along the ray, 0..1. @default 0.94 */ + decay?: number; + /** Overall gain on the scattering integral. @default 0.42 */ + exposure?: number; + /** Drift speed of the occluding haze. @default 1 */ + speed?: number; +} + +/** + * VolumetricRays — light shafts breaking through drifting haze, with the source + * pinned to the pointer. + * + * This is the radial light-scattering integral, the same one used as a + * post-process in real-time renderers. From each pixel the shader steps back + * toward the light in a straight line; at every step it evaluates the light + * that would arrive there — a bright falloff around the source, multiplied by + * how much of it a two-octave drifting noise field lets past — and adds it in, + * attenuated by a factor that compounds each step. Because every pixel marches + * along its own line to the same origin, near-source brightness gets smeared + * radially outward and the silhouette of the haze becomes the shaft. Decay is + * raised to a per-step power derived from the sample count, so raising `rays` + * refines the integral instead of dimming the frame. + */ +export function VolumetricRays({ + className, + color = "#b9c6ff", + rays = 34, + decay = 0.94, + exposure = 0.42, + speed = 1, +}: VolumetricRaysProps) { + const uniforms = useMemo( + () => ({ + uColor: hexToRgb(color), + uRays: rays, + uDecay: decay, + uExposure: exposure, + }), + [color, rays, decay, exposure], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +// The haze the light has to get through. Deliberately only two octaves — the +// march itself integrates them into structure, so paying for more is wasted. +float haze(vec2 p, float t) { + float n = vnoise(p * 3.4 + vec2(t * 0.22, -t * 0.13)); + n += 0.5 * vnoise(p * 7.7 - vec2(t * 0.17, t * 0.28)); + // Hard-ish edges on purpose: a soft occluder integrates into fog, and what + // makes a shaft legible is the sharp boundary between lit and blocked. + return smoothstep(0.40, 0.64, n / 1.5); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + float t = uTime; + + // Light sits high and centred until the pointer takes it over. + vec2 lp = mix(vec2(0.0, 0.30), pc, uPointer.z); + + float samples = clamp(uRays, 8.0, 64.0); + vec2 delta = (uv - lp) / samples * 0.94; + vec2 pos = uv; + float illum = 1.0; + float sum = 0.0; + + // Decay is per unit distance, not per step, so sample count is pure quality. + float dec = pow(clamp(uDecay, 0.60, 0.999), 32.0 / samples); + + for (int i = 0; i < 64; i++) { + if (float(i) >= samples) break; + pos -= delta; + float src = exp(-length(pos - lp) * 2.6); + sum += src * (1.0 - haze(pos * 3.1, t)) * illum; + illum *= dec; + } + sum = sum / samples * uExposure * 5.6; // mean along the ray, not the total + + float dl = length(uv - lp); + vec3 col = uColor * 0.012; + col += uColor * sum * exp(-dl * 0.75); + col += uColor * 0.09 * exp(-dl * 2.4); // soft bloom + col += uColor * 0.45 * exp(-dl * 8.0); // the source itself + + col = 1.0 - exp(-col * 1.15); // tone map — the source glows without clipping + col *= 1.0 - 0.30 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs index 5ec90f3..7540290 100644 --- a/scripts/build-registry.mjs +++ b/scripts/build-registry.mjs @@ -101,7 +101,16 @@ writeFileSync(join(root, "registry.json"), JSON.stringify(registry, null, 2) + " // ----- llms.txt ----- const byCat = {}; for (const [n, c] of Object.entries(meta.components)) (byCat[c.category] ||= []).push([n, c]); -const catTitle = { text: "Text Animations", interaction: "Interaction & Motion", components: "Components", physics: "Physics & Art" }; +const CATEGORY_ORDER = ["text", "interaction", "cursor", "scroll", "shader", "physics", "components"]; +const catTitle = { + text: "Text Animations", + interaction: "Interaction & Motion", + cursor: "Cursor & Pointer", + scroll: "Scroll-Driven", + shader: "Shaders & Light (WebGL2)", + physics: "Physics & Art", + components: "Components", +}; const lines = []; lines.push("# ReactOmega"); lines.push(""); @@ -114,7 +123,7 @@ lines.push(`npx shadcn@latest add ${BASE}/.json # via shadcn`); lines.push("```"); lines.push("Every component is a self-contained file copied into your project (you own the code). They use Tailwind + a tiny cn() helper, honor prefers-reduced-motion, and are screen-reader aware."); lines.push(""); -for (const cat of ["text", "interaction", "components", "physics"]) { +for (const cat of CATEGORY_ORDER) { if (!byCat[cat]) continue; lines.push(`## ${catTitle[cat]}`); for (const [n, c] of byCat[cat]) lines.push(`- \`${n}\` — ${c.description}`); diff --git a/scripts/test-registry.mjs b/scripts/test-registry.mjs index 37e589a..2e6e083 100644 --- a/scripts/test-registry.mjs +++ b/scripts/test-registry.mjs @@ -39,10 +39,31 @@ for (const name of compNames) { for (const name of primNames) ok(existsSync(join(root, `public/r/${name}.json`)), `primitive ${name} has a registry item`); console.log("accessibility contract (reduced-motion)"); +// A component satisfies the contract either directly, or transitively through a +// primitive that does — useShader owns the whole WebGL lifecycle, including +// rendering a single still frame instead of a loop, so the shaders that build on +// it inherit the guarantee rather than restating it. +const honorsDirectly = (src) => /prefers-reduced-motion/.test(src); +const safePrimitives = new Set(primNames.filter((n) => honorsDirectly(read(meta.primitives[n].source)))); +ok(safePrimitives.has("use-prefers-reduced-motion"), "the reduced-motion primitive honors the contract"); for (const name of compNames) { const src = read(`registry/reactomega/ui/${name}.tsx`); - const honored = /prefers-reduced-motion/.test(src) || /use-prefers-reduced-motion/.test(src); - ok(honored, `${name}: honors prefers-reduced-motion`); + const via = (meta.components[name].uses || []).filter((u) => safePrimitives.has(u)); + const honored = honorsDirectly(src) || via.length > 0; + ok(honored, `${name}: honors prefers-reduced-motion${!honorsDirectly(src) && via.length ? ` (via ${via.join(", ")})` : ""}`); +} + +console.log("WebGL lifecycle"); +// A canvas hands back the same context object on every getContext call, so calling +// WEBGL_lose_context in an effect cleanup poisons every later mount on that canvas: +// StrictMode re-runs effects in dev, and any dependency change re-runs them in prod. +// The failure is invisible to the compiler, to `next build`, and to a first render — +// it only shows up on the second mount, as a compile failure with a null info log. +for (const [name, p] of Object.entries(meta.primitives)) { + ok(!/loseContext/.test(read(p.source)), `${name}: does not force-lose the WebGL context`); +} +for (const name of compNames) { + ok(!/loseContext/.test(read(`registry/reactomega/ui/${name}.tsx`)), `${name}: does not force-lose the WebGL context`); } console.log("llms.txt"); diff --git a/web/app/components/page.tsx b/web/app/components/page.tsx index f0a6ebe..d473e28 100644 --- a/web/app/components/page.tsx +++ b/web/app/components/page.tsx @@ -12,18 +12,27 @@ export default function ComponentsPage() {

Components

- {DEMOS.length} copy-paste components across text, interaction, classics, and real physics. Hover, click and - drag — every demo below is live. Copy the install command on any card. + {DEMOS.length} copy-paste components across kinetic type, pointer interaction, cursors, scroll-driven + motion, raw-WebGL2 shaders, real physics, and UI. Hover, click, drag and scroll — every demo below is live. + Copy the install command on any card.

{CATEGORIES.map((cat) => { - const items = DEMOS.filter((d) => d.category === cat.id); + // Scroll-driven pieces that need the whole page are shown together at the + // bottom — inlining them here would leave a screen of void in the grid. + const items = DEMOS.filter((d) => d.category === cat.id && !d.tall); + const deferred = DEMOS.filter((d) => d.category === cat.id && d.tall); if (!items.length) return null; return (

{cat.label}

- {items.length} + {items.length + deferred.length} + {deferred.length ? ( + + {deferred.length} more need the whole page ↓ + + ) : null}
{items.map((d) => ( @@ -31,7 +40,7 @@ export default function ComponentsPage() { key={d.name} className="group overflow-hidden rounded-2xl border border-white/10 bg-neutral-950 transition-colors hover:border-violet-500/40" > - {d.node} + {d.node}
{d.title} @@ -44,6 +53,28 @@ export default function ComponentsPage() { })}
+
+
+

Full height

+ {DEMOS.filter((d) => d.tall).length} +
+

+ These two are driven by the page scroller and pin themselves to the viewport, so a 200px preview tile would + tell you nothing. Scroll through them. +

+
+ {DEMOS.filter((d) => d.tall).map((d) => ( +
+
{d.node}
+
+ {d.title} + +
+
+ ))} +
+
+ diff --git a/web/app/page.tsx b/web/app/page.tsx index ee7b869..f1aca2b 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -12,14 +12,26 @@ import { VariableProximity } from "@/components/reactomega/variable-proximity"; import { GravityText } from "@/components/reactomega/gravity-text"; import { SpringMesh } from "@/components/reactomega/spring-mesh"; import { Metaballs } from "@/components/reactomega/metaballs"; +import { LiquidMetal } from "@/components/reactomega/liquid-metal"; +import { ThinFilm } from "@/components/reactomega/thin-film"; +import { CurlFlow } from "@/components/reactomega/curl-flow"; +import { Caustics } from "@/components/reactomega/caustics"; +import { DEMOS } from "@/components/site/demos"; const FEATURED = [ { title: "variable-proximity", node:
}, { title: "gravity-text", node:
}, + { title: "liquid-metal", node: }, { title: "spring-mesh", node: }, { title: "metaballs", node: }, ]; +const SHADERS = [ + { title: "thin-film", blurb: "Real thin-film interference, not a hue rotation.", node: }, + { title: "curl-flow", blurb: "Divergence-free curl noise, advected into silk.", node: }, + { title: "caustics", blurb: "Folded coordinates focused into light filaments.", node: }, +]; + const WHY = [ { h: "You own the code", p: "Like shadcn/ui, every component is one self-contained file copied into your repo. No black-box package, no lock-in." }, { h: "AI-native", p: "A machine-readable registry + an MCP server, so AI agents (Claude, Cursor) install components with structured contracts — not just humans reading docs." }, @@ -41,7 +53,7 @@ export default function Home() {
- AI-native React registry · 31 components + AI-native React registry · {DEMOS.length} components

@@ -96,7 +108,7 @@ export default function Home() {

A few, live

- See all 31 → + See all {DEMOS.length} →
@@ -115,6 +127,36 @@ export default function Home() {

+ {/* shaders — the v1.1 headline, and the reason the zero-dependency claim is worth something */} +
+
+
+
+

Shaders, without a shader library

+

+ Six full-screen fragment shaders on hand-written GLSL and raw WebGL2 — no ogl, no three, still zero + runtime dependencies. A shared useShader primitive + owns sizing, off-screen suspension, context-loss recovery and the still frame reduced-motion users get. +

+
+ + All six → + +
+
+ {SHADERS.map((sh) => ( +
+ {sh.node} +
+
{sh.title}
+

{sh.blurb}

+
+
+ ))} +
+
+
+
MIT © Ed Chen · the AI-native React component registry · GitHub diff --git a/web/components/reactomega/bento-grid.tsx b/web/components/reactomega/bento-grid.tsx new file mode 100644 index 0000000..325861c --- /dev/null +++ b/web/components/reactomega/bento-grid.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { createContext, useContext, useEffect, useId, useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +/** Called once per animation frame with grid-relative pointer coordinates. */ +type TileFrame = (gx: number, gy: number, inside: boolean) => void; + +interface BentoContextValue { + register: (fn: TileFrame) => () => void; + columns: number; + tilt: number; + spotlight: boolean; + reduced: boolean; +} + +const BentoContext = createContext({ + register: () => () => {}, + columns: 4, + tilt: 0, + spotlight: false, + reduced: true, +}); + +/** px beyond a tile's edge at which its border glow reaches zero. */ +const GLOW_FALLOFF = 220; + +export interface BentoGridProps extends React.HTMLAttributes { + /** Extra classes for the grid. @default undefined */ + className?: string; + /** `BentoTile` children. @default undefined */ + children?: React.ReactNode; + /** Columns at the large breakpoint — halved at `sm`, single column below. @default 4 */ + columns?: number; + /** Gutter in px. @default 16 */ + gap?: number; + /** Minimum height of each grid row in px. @default 140 */ + rowHeight?: number; + /** Track the pointer with a radial spotlight that crosses tile boundaries. @default true */ + spotlight?: boolean; + /** Spotlight colour at its centre. @default "rgba(124,92,255,.20)" */ + spotlightColor?: string; + /** Maximum per-tile tilt in degrees, 0 disables. @default 6 */ + tilt?: number; +} + +/** + * BentoGrid — a responsive bento layout that behaves like one surface rather + * than a pile of independent cards. The grid owns the *only* pointer listener: + * on each animation frame it reads its own live `getBoundingClientRect()` and + * publishes grid-relative coordinates as the inherited custom properties + * `--ro-gx` / `--ro-gy`. Because custom properties cascade, every tile's + * spotlight is pure CSS — each tile subtracts its own `offsetLeft` / `offsetTop` + * (`--ro-ox` / `--ro-oy`) inside `calc()`, so a single radial gradient appears + * continuous across gutters with zero per-tile listeners. Effects that CSS + * cannot express from those variables — the 3D tilt and the proximity border + * glow — are driven by the same frame: tiles subscribe a callback and the grid + * fans out to them, writing `style.transform` directly instead of through React + * state. Only transforms and custom properties are written, so layout stays + * clean and the per-frame offset reads stay cheap. + * + * Reduced motion detaches the listener entirely: no tracking, no tilt, no + * per-frame work. Tiles fall back to a static hover border so they still read + * as interactive, and links stay links. + */ +export function BentoGrid({ + className, + children, + columns = 4, + gap = 16, + rowHeight = 140, + spotlight = true, + spotlightColor = "rgba(124,92,255,.20)", + tilt = 6, + style, + ...rest +}: BentoGridProps) { + const reduced = usePrefersReducedMotion(); + const gridRef = useRef(null); + const subs = useRef>(new Set()); + const cls = "ro-bento-" + useId().replace(/[^a-zA-Z0-9-]/g, ""); + + const ctx = useMemo( + () => ({ + register: (fn: TileFrame) => { + subs.current.add(fn); + return () => { + subs.current.delete(fn); + }; + }, + columns: Math.max(1, Math.round(columns)), + tilt: reduced ? 0 : Math.max(0, tilt), + spotlight: spotlight && !reduced, + reduced, + }), + [columns, tilt, spotlight, reduced], + ); + + useEffect(() => { + const grid = gridRef.current; + if (!grid || reduced || (!spotlight && tilt <= 0)) return; + + let raf = 0; + let cx = 0; + let cy = 0; + let inside = false; + + const frame = () => { + raf = 0; + // Live rect every frame — the page scrolls and a cached rect goes stale. + const r = grid.getBoundingClientRect(); + const gx = inside ? cx - r.left : -9999; + const gy = inside ? cy - r.top : -9999; + grid.style.setProperty("--ro-gx", `${gx}px`); + grid.style.setProperty("--ro-gy", `${gy}px`); + subs.current.forEach((fn) => fn(gx, gy, inside)); + }; + const schedule = () => { + if (!raf) raf = requestAnimationFrame(frame); + }; + const onMove = (e: PointerEvent) => { + cx = e.clientX; + cy = e.clientY; + inside = true; + schedule(); + }; + const onLeave = () => { + inside = false; + schedule(); + }; + + grid.addEventListener("pointermove", onMove, { passive: true }); + grid.addEventListener("pointerleave", onLeave); + return () => { + cancelAnimationFrame(raf); + grid.removeEventListener("pointermove", onMove); + grid.removeEventListener("pointerleave", onLeave); + }; + }, [reduced, spotlight, tilt]); + + const cols = Math.max(1, Math.round(columns)); + const sm = Math.min(2, cols); + + return ( +
+ + {children} +
+ ); +} + +export interface BentoTileProps extends React.HTMLAttributes { + /** Extra classes for the tile. @default undefined */ + className?: string; + /** Tile content. @default undefined */ + children?: React.ReactNode; + /** Columns to span at the large breakpoint, clamped to the grid width. @default 1 */ + colSpan?: number; + /** Rows to span at the large breakpoint. @default 1 */ + rowSpan?: number; + /** Render as a link. Tiles with an href are real anchors and keyboard reachable. @default undefined */ + href?: string; +} + +/** + * BentoTile — one cell of a `BentoGrid`. Its span collapses to a single column + * on small screens (a 2-wide tile inside a 1-column grid would otherwise create + * a phantom track), widens to at most two at `sm`, and takes its full span at + * `lg`. It subscribes to the grid's shared pointer frame to write its own tilt + * transform and `--ro-glow` proximity value, and exposes its `offsetLeft` / + * `offsetTop` as custom properties so the grid-wide spotlight can be positioned + * from CSS alone. With an `href` it renders an ``; without one it is a plain + * container, never a div pretending to be a button. + */ +export function BentoTile({ + className, + children, + colSpan = 1, + rowSpan = 1, + href, + style, + ...rest +}: BentoTileProps) { + const { register, columns, tilt, spotlight, reduced } = useContext(BentoContext); + const ref = useRef(null); + const cls = "ro-tile-" + useId().replace(/[^a-zA-Z0-9-]/g, ""); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const sync = () => { + el.style.setProperty("--ro-ox", `${el.offsetLeft}px`); + el.style.setProperty("--ro-oy", `${el.offsetTop}px`); + }; + sync(); + if (reduced) return; + + const onFrame: TileFrame = (gx, gy, inside) => { + const ox = el.offsetLeft; + const oy = el.offsetTop; + const w = el.offsetWidth || 1; + const h = el.offsetHeight || 1; + el.style.setProperty("--ro-ox", `${ox}px`); + el.style.setProperty("--ro-oy", `${oy}px`); + + const dx = Math.max(ox - gx, 0, gx - (ox + w)); + const dy = Math.max(oy - gy, 0, gy - (oy + h)); + const dist = Math.hypot(dx, dy); + el.style.setProperty("--ro-glow", inside ? Math.max(0, 1 - dist / GLOW_FALLOFF).toFixed(3) : "0"); + + if (tilt > 0) { + if (inside && dist === 0) { + const px = (gx - ox) / w; + const py = (gy - oy) / h; + el.style.transform = `perspective(900px) rotateX(${((0.5 - py) * 2 * tilt).toFixed(2)}deg) rotateY(${( + (px - 0.5) * + 2 * + tilt + ).toFixed(2)}deg)`; + } else { + el.style.transform = "perspective(900px) rotateX(0deg) rotateY(0deg)"; + } + } + }; + const off = register(onFrame); + return () => off(); + }, [register, tilt, reduced]); + + const c = Math.max(1, Math.min(Math.round(colSpan), Math.max(1, columns))); + const sm = Math.min(c, 2); + const r = Math.max(1, Math.round(rowSpan)); + + const shell = cn( + "group relative isolate block overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5", + "outline-none backdrop-blur will-change-transform", + "focus-visible:ring-2 focus-visible:ring-[#7c5cff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#06060a]", + reduced && "transition-colors hover:border-white/25", + cls, + className, + ); + + const spans = ( + + ); + + const inner = ( + <> + {spotlight && ( +
+ )} + {!reduced && ( +
+ )} +
{children}
+ + ); + + const inlineStyle: React.CSSProperties = { + transition: reduced ? undefined : "transform .18s cubic-bezier(.2,.7,.3,1)", + ...style, + }; + + if (href) { + return ( + <> + {spans} +
)} + href={href} + ref={(el) => { + ref.current = el; + }} + className={shell} + style={inlineStyle} + > + {inner} + + + ); + } + + return ( + <> + {spans} +
{ + ref.current = el; + }} + className={shell} + style={inlineStyle} + > + {inner} +
+ + ); +} diff --git a/web/components/reactomega/card-swap.tsx b/web/components/reactomega/card-swap.tsx new file mode 100644 index 0000000..4f06927 --- /dev/null +++ b/web/components/reactomega/card-swap.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { Children, useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface CardSwapProps extends Omit, "onChange" | "children"> { + /** Cards to stack. Each child becomes one card face. */ + children: React.ReactNode; + /** Extra classes for the deck container. @default undefined */ + className?: string; + /** ms the front card holds before the deck advances. @default 3400 */ + interval?: number; + /** Floor height of the stage in px — the deck needs room for its depth. @default 280 */ + minHeight?: number; + /** px each card behind the front is nudged right (and up). @default 26 */ + offset?: number; + /** px of Z distance between neighbouring cards. @default 62 */ + depth?: number; + /** Degrees of Y-rotation added per card of depth. @default 5 */ + rotation?: number; + /** Advance the deck on a timer. @default true */ + autoplay?: boolean; + /** Called with the new front-card index whenever the deck moves. @default undefined */ + onChange?: (index: number) => void; +} + +/** How many cards behind the front stay visible (and get reserved space). */ +const VISIBLE_LAYERS = 3; +/** ms the outgoing card spends flying forward before the deck re-indexes. */ +const PEEL_MS = 260; +/** ms every card takes to glide to its new slot. */ +const SETTLE_MS = 520; + +/** + * CardSwap — a 3D deck where cards recede into the distance and the front one + * peels away to reveal the next. Every card sits in the same absolutely + * positioned stage under a shared `perspective`; its slot is derived purely + * from `d`, its distance from the front, as + * `translate3d(d·offset, -d·offset·0.55, -d·depth) rotateY(d·rotation)`, so the + * whole deck is one pure function of the active index and CSS transitions do + * the interpolation. Advancing runs in two phases: the outgoing card is thrown + * forward on the top z-layer for `PEEL_MS`, then the index advances and it + * drops to the back z-layer and slides home behind the stack. The stage is + * inset by the total stack offset so cards never clip out of the container, + * whatever children you pass. + * + * Accessibility: every card is a real ` + ); + })} +
+
+ {n ? `Card ${active + 1} of ${n}` : "Empty deck"} +
+
+ ); +} diff --git a/web/components/reactomega/caustics.tsx b/web/components/reactomega/caustics.tsx new file mode 100644 index 0000000..2cd110b --- /dev/null +++ b/web/components/reactomega/caustics.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface CausticsProps { + className?: string; + /** Colour of the focused light. @default "#a9c4ff" */ + color?: string; + /** Colour of the unlit floor. @default "#06060a" */ + background?: string; + /** Brightness of the filaments. @default 1 */ + intensity?: number; + /** Zoom of the caustic web — higher packs more cells in. @default 1.35 */ + scale?: number; + /** Speed of the water above. @default 1 */ + speed?: number; +} + +/** + * Caustics — the net of focused light that a rippling water surface throws onto + * the floor beneath it. + * + * Built the way caustics are actually cheap to fake: a coordinate is folded + * back on itself five times, each pass displacing it by sines and cosines of + * its own components at a different temporal rate, and each pass accumulates + * the reciprocal distance to that folded position. Summing inverse distances + * is what concentrates energy into thin bright filaments where many folds + * happen to land together — the same reason real caustics are the envelope of + * refracted rays. The accumulation is then gamma-crushed hard so the mid-tones + * fall away and only the focus lines survive, and the crush exponent differs + * slightly per channel so the filaments carry a faint prismatic edge. The + * pointer drops a decaying radial phase wave into the surface, which bends the + * web outward around the cursor. + */ +export function Caustics({ + className, + color = "#a9c4ff", + background = "#06060a", + intensity = 1, + scale = 1.35, + speed = 1, +}: CausticsProps) { + const uniforms = useMemo( + () => ({ + uColor: hexToRgb(color), + uBg: hexToRgb(background), + uIntensity: intensity, + uScale: scale, + }), + [color, background, intensity, scale], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.42 + 23.0; + // The fold works on a large offset coordinate — at small |p| the reciprocal + // distance saturates on every pass and the field flattens to a single tone. + vec2 p = uv * uScale * 6.2831853 - 250.0; + + // A stone in the water above: a radial phase wave that displaces the sample + // point, so the whole web refracts outward from the cursor rather than just + // getting brighter under it. + vec2 rel = uv - pc; + float r = length(rel); + float ripple = uPointer.z * sin(r * 24.0 - uTime * 4.6) * exp(-r * 4.5) * 0.85; + p += normalize(rel + 1e-5) * ripple; + + // Fold the coordinate into itself; accumulate inverse distance each pass. + vec2 q = p; + float c = 1.0; + const float INTEN = 0.0045; + for (int n = 0; n < 5; n++) { + float tn = t * (1.0 - (3.2 / float(n + 1))); + q = p + vec2(cos(tn - q.x) + sin(tn + q.y), sin(tn - q.y) + cos(tn + q.x)); + float sx = sin(q.x + tn) / INTEN; + float sy = cos(q.y + tn) / INTEN; + c += 1.0 / length(vec2(p.x / (sx + 1e-4), p.y / (sy + 1e-4))); + } + c = 1.16 - pow(c / 5.0, 1.42); + // Clamp before the gamma crush: c is an unbounded reciprocal-distance sum, + // and pow(a, 7.9) on anything above 1.0 runs away to pure white. + float a = clamp(abs(c), 0.0, 1.0); + + // Slightly different crush per channel: blue filaments end up marginally + // wider than red, which reads as dispersion along every focus line. + vec3 web = vec3(pow(a, 9.4), pow(a, 8.6), pow(a, 7.6)) * uIntensity; + + vec3 col = uBg + uColor * web * 1.45; + col += uColor * 0.07 * pow(a, 3.4) * uIntensity; // the broad underwater wash + col += uColor * uPointer.z * 0.15 * exp(-r * 5.5); // glow where the stone fell + + col = 1.0 - exp(-col * 1.9); // tone map — filaments stay bright, nothing clips + col *= 1.0 - 0.34 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/circular-gallery.tsx b/web/components/reactomega/circular-gallery.tsx new file mode 100644 index 0000000..6c0a55b --- /dev/null +++ b/web/components/reactomega/circular-gallery.tsx @@ -0,0 +1,400 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface CircularGalleryItem { + /** Image URL. */ + src: string; + /** Alternative text. Use "" for purely decorative imagery. */ + alt: string; + /** Caption shown under the image and announced as the item's name. */ + label: string; +} + +export interface CircularGalleryProps extends Omit, "onSelect" | "children"> { + /** Extra classes for the gallery shell. @default undefined */ + className?: string; + /** Items placed evenly around the cylinder. @default [] */ + items?: CircularGalleryItem[]; + /** Cylinder radius in px — larger is a flatter arc. @default 420 */ + radius?: number; + /** Width of one item in px. @default 220 */ + itemWidth?: number; + /** Settle to the nearest item once the spin decays. @default true */ + snap?: boolean; + /** Per-frame velocity multiplier after release, 0–1. @default 0.94 */ + friction?: number; + /** Idle spin in degrees per second, 0 disables. @default 0 */ + autoRotate?: number; + /** Fires when an item is chosen. @default undefined */ + onSelect?: (index: number, item: CircularGalleryItem) => void; +} + +/** px of pointer travel before a press becomes a drag rather than a tap. */ +const DRAG_SLOP = 4; + +const mod = (a: number, n: number) => ((a % n) + n) % n; + +/** + * CircularGallery — items pinned to the surface of a horizontal cylinder in CSS + * 3D. Each item is fixed at `rotateY(i · 360/n) translateZ(radius)`, and only + * the stage they share is animated: `translateZ(-radius) rotateY(rot)` pushes + * the cylinder back by its own radius so the front item lands on the screen + * plane at true size instead of looming at the camera. Dragging converts + * pointer travel to rotation through the arc length of the cylinder + * (`360 / 2πr` degrees per pixel), so the surface tracks the finger 1:1 whatever + * the radius. Release hands the last frame's delta to an inertial integrator + * that decays by `friction` each frame and then, if `snap` is on, eases to the + * nearest multiple of the angle step — chosen as the equivalent angle closest + * to the current rotation, so it never unwinds the long way round. Depth is + * read back from each item's world angle: `cos` of the angle to the viewer + * drives opacity and scale, and items past the horizon lose pointer events so + * you cannot click the back of the cylinder through the front. Rotation is + * written straight to `style.transform` every frame; only the active index is + * React state. + * + * Accessibility: every item is a real ` + ))} +
+
+ +
+ + +
+ +
+ {n ? `${items[active]?.label ?? ""}, item ${active + 1} of ${n}` : "Empty gallery"} +
+
+ ); +} diff --git a/web/components/reactomega/crosshair.tsx b/web/components/reactomega/crosshair.tsx new file mode 100644 index 0000000..4a1449c --- /dev/null +++ b/web/components/reactomega/crosshair.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface CrosshairProps { + /** Content the crosshair is laid over. */ + children?: React.ReactNode; + className?: string; + /** Hairline and reticle color. @default "#7c5cff" */ + color?: string; + /** Hairline weight in px. @default 1 */ + thickness?: number; + /** Elements the crosshair locks onto. @default "[data-snap]" */ + snapSelector?: string; + /** Px from a target's edge at which the lock engages. @default 80 */ + snapDistance?: number; + /** Show the live coordinate readout. @default true */ + showCoords?: boolean; +} + +/** + * Crosshair — a precision-instrument overlay: full-bleed hairlines tracking the + * pointer, a corner-bracket reticle, and a monospace readout of the pointer's + * position in container space. + * + * Its one real trick is snapping. Each frame the nearest element matching + * `snapSelector` is measured with the clamped point-to-rectangle distance — zero + * while the pointer is inside the target, so a hover always locks — and if that + * is within `snapDistance` the crosshair acquires it. Acquisition drives a single + * eased 0..1 value that the hairline position interpolates along, from the raw + * pointer to the target's centre, while the reticle simultaneously grows from an + * 18px box into a bracket around the target's bounding box. One scalar, both + * behaviours, so the lock can never half-engage. + * + * Every rect — the container's and each target's — is read live each frame, so + * the instrument stays calibrated through scrolls, resizes and layout shifts, and + * targets added after mount are picked up automatically. Box dimensions are only + * written when they actually change, keeping the steady state to pure transforms. + * The loop idles once the lock has settled. The overlay is inert and aria-hidden; + * the children below it stay fully clickable and keyboard reachable. Reduced + * motion keeps the snapping but removes the glide, and coarse pointers get the + * children with no overlay at all. + */ +export function Crosshair({ + children, + className, + color = "#7c5cff", + thickness = 1, + snapSelector = "[data-snap]", + snapDistance = 80, + showCoords = true, +}: CrosshairProps) { + const wrapRef = useRef(null); + const layerRef = useRef(null); + const hRef = useRef(null); + const vRef = useRef(null); + const boxRef = useRef(null); + const labelRef = useRef(null); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + useEffect(() => { + if (!fine) return; + const wrap = wrapRef.current; + const layer = layerRef.current; + const hLine = hRef.current; + const vLine = vRef.current; + const box = boxRef.current; + if (!wrap || !layer || !hLine || !vLine || !box) return; + + let safeSelector = snapSelector; + try { + wrap.querySelector(snapSelector); + } catch { + safeSelector = ""; + } + + const BASE = 18; // reticle size with nothing acquired + let px = 0; + let py = 0; + let inside = false; + let lock = 0; // eased 0..1 acquisition + let lastW = -1; + let lastH = -1; + let lastText = ""; + let raf = 0; + let running = false; + + const step = () => { + const r = wrap.getBoundingClientRect(); + + // Nearest target by clamped point-to-rect distance (0 when pointer is inside it). + let tx = px; + let ty = py; + let tw = BASE; + let th = BASE; + let found = false; + if (inside && safeSelector) { + let best = Infinity; + const targets = wrap.querySelectorAll(safeSelector); + for (let i = 0; i < targets.length; i++) { + const b = targets[i].getBoundingClientRect(); + const left = b.left - r.left; + const top = b.top - r.top; + const dx = Math.max(left - px, 0, px - (left + b.width)); + const dy = Math.max(top - py, 0, py - (top + b.height)); + const d = Math.hypot(dx, dy); + if (d <= snapDistance && d < best) { + best = d; + tx = left + b.width / 2; + ty = top + b.height / 2; + tw = b.width + 10; + th = b.height + 10; + found = true; + } + } + } + + const target = found ? 1 : 0; + if (reduced) lock = target; + else lock += (target - lock) * 0.3; + const e = lock * lock * (3 - 2 * lock); // smoothstep + + const cx = px + (tx - px) * e; + const cy = py + (ty - py) * e; + const bw = BASE + (tw - BASE) * e; + const bh = BASE + (th - BASE) * e; + + hLine.style.transform = `translate3d(0, ${(cy - thickness / 2).toFixed(2)}px, 0)`; + vLine.style.transform = `translate3d(${(cx - thickness / 2).toFixed(2)}px, 0, 0)`; + box.style.transform = `translate3d(${(cx - bw / 2).toFixed(2)}px, ${(cy - bh / 2).toFixed(2)}px, 0)`; + // Layout writes only when the bracket actually resizes. + if (Math.abs(bw - lastW) > 0.5) { + lastW = bw; + box.style.width = `${bw.toFixed(1)}px`; + } + if (Math.abs(bh - lastH) > 0.5) { + lastH = bh; + box.style.height = `${bh.toFixed(1)}px`; + } + + const label = labelRef.current; + if (label) { + const text = `${Math.round(cx)}, ${Math.round(cy)}`; + if (text !== lastText) { + lastText = text; + label.textContent = text; + } + // Keep the readout inside the box by flipping it near the far edges. + const flipX = cx > r.width - 96; + const flipY = cy > r.height - 40; + label.style.transform = + `translate3d(${(cx + (flipX ? -14 : 14)).toFixed(1)}px, ${(cy + (flipY ? -14 : 14)).toFixed(1)}px, 0)` + + ` translate(${flipX ? "-100%" : "0"}, ${flipY ? "-100%" : "0"})`; + } + + if (!reduced && Math.abs(target - lock) > 0.002) raf = requestAnimationFrame(step); + else running = false; + }; + + const ensure = () => { + if (!running) { + running = true; + raf = requestAnimationFrame(step); + } + }; + + const onMove = (e: PointerEvent) => { + const r = wrap.getBoundingClientRect(); + px = e.clientX - r.left; + py = e.clientY - r.top; + inside = true; + ensure(); + }; + const onEnter = () => { + inside = true; + layer.style.opacity = "1"; + ensure(); + }; + const onLeave = () => { + inside = false; + layer.style.opacity = "0"; + ensure(); + }; + + wrap.addEventListener("pointerenter", onEnter); + wrap.addEventListener("pointermove", onMove, { passive: true }); + wrap.addEventListener("pointerleave", onLeave); + ensure(); + + return () => { + cancelAnimationFrame(raf); + wrap.removeEventListener("pointerenter", onEnter); + wrap.removeEventListener("pointermove", onMove); + wrap.removeEventListener("pointerleave", onLeave); + }; + }, [fine, reduced, color, thickness, snapSelector, snapDistance, showCoords]); + + // borderStyle alone would default the unset sides to `medium` and draw a full + // box, so every side starts at zero width and the caller adds back just two. + const corner = (style: React.CSSProperties) => ( + + ); + + return ( +
+ {children} + {fine && ( +
+
+
+
+ {corner({ left: -1, top: -1, borderLeftWidth: thickness + 1, borderTopWidth: thickness + 1 })} + {corner({ right: -1, top: -1, borderRightWidth: thickness + 1, borderTopWidth: thickness + 1 })} + {corner({ left: -1, bottom: -1, borderLeftWidth: thickness + 1, borderBottomWidth: thickness + 1 })} + {corner({ right: -1, bottom: -1, borderRightWidth: thickness + 1, borderBottomWidth: thickness + 1 })} +
+ {showCoords && ( +
+ )} +
+ )} +
+ ); +} diff --git a/web/components/reactomega/curl-flow.tsx b/web/components/reactomega/curl-flow.tsx new file mode 100644 index 0000000..ef3c22e --- /dev/null +++ b/web/components/reactomega/curl-flow.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface CurlFlowProps { + className?: string; + /** Colour of the silk. @default "#9b8cff" */ + tint?: string; + /** Colour in the folds. @default "#06060a" */ + background?: string; + /** Zoom of the flow field — higher gives narrower bands. @default 2.2 */ + scale?: number; + /** Advection steps, 1..8. More steps means longer, silkier streamlines. @default 5 */ + steps?: number; + /** Flow speed multiplier. @default 1 */ + speed?: number; + /** Band hardness — higher pinches the highlights. @default 1.15 */ + contrast?: number; +} + +/** + * CurlFlow — wide bands of silk folding through each other, driven by a real + * curl-noise field. + * + * A three-octave FBM is treated as a scalar stream function ψ. The velocity is + * the perpendicular of its gradient, (∂ψ/∂y, −∂ψ/∂x), which is divergence-free + * by construction — that is the whole trick, and it is why the flow swirls and + * shears without ever piling up into a source or draining into a sink the way + * a raw noise-vector field does. Each pixel walks its coordinate a few steps + * along that velocity, so it ends up sampling the value it had upstream; ψ read + * at the advected position is therefore stretched along the streamlines, and a + * sine of it becomes bands that follow the flow. The highlight is anisotropic: + * brightness depends on the alignment of the local tangent with the light, the + * way sheen on real silk depends on fibre direction rather than surface normal. + * The pointer adds a genuine vortex — a tangential term about the cursor — + * into the velocity before each step, so the fabric twists rather than dents. + */ +export function CurlFlow({ + className, + tint = "#9b8cff", + background = "#06060a", + scale = 2.2, + steps = 5, + speed = 1, + contrast = 1.15, +}: CurlFlowProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uBg: hexToRgb(background), + uScale: scale, + uSteps: steps, + uContrast: contrast, + }), + [tint, background, scale, steps, contrast], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm3(vec2 p) { + float a = 0.5; + float v = 0.0; + for (int i = 0; i < 3; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +// Scalar stream function. +float potential(vec2 p, float t) { + return fbm3(p + vec2(t * 0.09, t * 0.16)); +} + +// Curl of that potential: perpendicular of the gradient, so divergence is zero +// and the field can only rotate and shear. Forward differences — three taps +// instead of the four a central difference would cost, and the bias is invisible. +vec2 curl(vec2 p, float t) { + const float e = 0.045; + float a = potential(p, t); + float gx = (potential(p + vec2(e, 0.0), t) - a) / e; + float gy = (potential(p + vec2(0.0, e), t) - a) / e; + return vec2(gy, -gx); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + float t = uTime * 0.3; + + vec2 q = uv * uScale; + vec2 pcs = pc * uScale; + float steps = clamp(uSteps, 1.0, 8.0); + float h = 0.9 / steps; // total advected distance stays constant + vec2 vel = vec2(0.0); + + for (int i = 0; i < 8; i++) { + if (float(i) >= steps) break; + vel = curl(q, t); + vec2 r = q - pcs; + float w = uPointer.z * exp(-dot(r, r) * 1.7); + vel += vec2(-r.y, r.x) * w * 3.4; // vortex injected at the cursor + q += vel * h * 0.60; + } + + float s = potential(q, t); + + // Anisotropic sheen: silk is bright where the fibre tangent lines up with the + // light, so the highlight rides the flow direction, not a surface normal. + vec2 tangent = normalize(vel + vec2(1e-5, 1e-5)); + float aniso = pow(abs(dot(tangent, vec2(0.55, 0.84))), 3.5); + + float band = 0.5 + 0.5 * sin(s * 15.0 + t * 1.7); + band = pow(band, max(uContrast, 0.05) * 3.0); + + float shade = clamp(band * (0.42 + 0.85 * aniso) + 0.05 * length(vel), 0.0, 1.0); + + vec3 hi = clamp(uTint * 1.45 + 0.16, 0.0, 1.0); + vec3 col = mix(uBg, uTint, shade); + col += hi * pow(shade, 4.5) * 0.55; + + col *= 1.0 - 0.38 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/elastic-cursor.tsx b/web/components/reactomega/elastic-cursor.tsx new file mode 100644 index 0000000..f5305e0 --- /dev/null +++ b/web/components/reactomega/elastic-cursor.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ElasticCursorProps { + className?: string; + /** Diameter of the lead blob in px. @default 34 */ + size?: number; + /** Blob color. @default "#7c5cff" */ + color?: string; + /** How far the blob stretches at full speed, as a fraction of its size. @default 0.55 */ + stretch?: number; + /** Spring acceleration, 0..1 — higher chases the pointer harder. @default 0.3 */ + stiffness?: number; + /** Spring damping, 0..1 — lower settles with less wobble. @default 0.5 */ + damping?: number; + /** Number of smaller blobs chained behind the lead one. @default 2 */ + trail?: number; + /** Merge the blobs through an SVG goo filter. @default true */ + goo?: boolean; +} + +/** + * ElasticCursor — a gooey blob that deforms along its own velocity vector. Each + * frame the blob's spring velocity gives both a direction and a speed: the blob + * rotates to `atan2(vy, vx)` and scales by `1 + k` along that axis while scaling + * by `1 / (1 + k)` across it, so the product stays 1 and the blob conserves its + * area — it stretches into a teardrop under a flick and relaxes back to a perfect + * circle at rest, never inflating. + * + * Speed maps to stretch through `1 - exp(-v / 14)`, a saturating curve, so there + * is no clamp discontinuity when the pointer is thrown across the screen. Trailing + * blobs are a follower chain — each one springs toward the one ahead with reduced + * stiffness — and an SVG goo filter (blur plus an alpha contrast ramp) welds them + * into a single surface that necks and snaps. The filter id comes from `useId()` + * so several instances never collide. + * + * The loop idles once every blob has caught up. Coarse pointers render nothing; + * reduced motion pins every blob to the pointer with no stretch and no rotation. + */ +export function ElasticCursor({ + className, + size = 34, + color = "#7c5cff", + stretch = 0.55, + stiffness = 0.3, + damping = 0.5, + trail = 2, + goo = true, +}: ElasticCursorProps) { + const rootRef = useRef(null); + const blobRefs = useRef<(HTMLDivElement | null)[]>([]); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + const filterId = "ro-elastic-" + useId().replace(/[^a-zA-Z0-9-]/g, ""); + + const count = Math.max(1, Math.round(trail) + 1); + const diameter = (i: number) => Math.round(size * Math.max(0.35, 1 - 0.22 * i)); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + useEffect(() => { + if (!fine) return; + const root = rootRef.current; + if (!root) return; + const blobs = blobRefs.current.slice(0, count).filter(Boolean) as HTMLDivElement[]; + if (!blobs.length) return; + + const state = blobs.map(() => ({ x: -9999, y: -9999, vx: 0, vy: 0, a: 0 })); + let px = -9999; + let py = -9999; + let seen = false; + let raf = 0; + let running = false; + + const step = () => { + const r = root.getBoundingClientRect(); + let active = false; + + for (let i = 0; i < blobs.length; i++) { + const s = state[i]; + // Blob 0 chases the pointer; every other blob chases the one in front. + const tx = i === 0 ? px : state[i - 1].x; + const ty = i === 0 ? py : state[i - 1].y; + + if (reduced) { + s.x = tx; + s.y = ty; + s.vx = 0; + s.vy = 0; + } else { + const k = stiffness * Math.pow(0.72, i); + s.vx = (s.vx + (tx - s.x) * k) * damping; + s.vy = (s.vy + (ty - s.y) * k) * damping; + s.x += s.vx; + s.y += s.vy; + } + + const speed = Math.hypot(s.vx, s.vy); + // Saturating response: fast flicks approach full stretch without a clamp. + const t = reduced ? 0 : 1 - Math.exp(-speed / 14); + const sx = 1 + stretch * t; + const sy = 1 / sx; // area preserved: sx * sy === 1 + if (speed > 0.6) s.a = (Math.atan2(s.vy, s.vx) * 180) / Math.PI; + + blobs[i].style.transform = + `translate3d(${(s.x - r.left).toFixed(2)}px, ${(s.y - r.top).toFixed(2)}px, 0)` + + ` translate(-50%, -50%) rotate(${s.a.toFixed(2)}deg) scale(${sx.toFixed(3)}, ${sy.toFixed(3)})`; + + if (Math.abs(tx - s.x) > 0.05 || Math.abs(ty - s.y) > 0.05 || speed > 0.05) active = true; + } + + if (active) raf = requestAnimationFrame(step); + else running = false; + }; + + const ensure = () => { + if (!running) { + running = true; + raf = requestAnimationFrame(step); + } + }; + + const onMove = (e: PointerEvent) => { + px = e.clientX; + py = e.clientY; + if (!seen) { + seen = true; + for (const s of state) { + s.x = px; + s.y = py; + } + root.style.opacity = "1"; + } + ensure(); + }; + const onOut = (e: PointerEvent) => { + if (e.relatedTarget) return; + root.style.opacity = "0"; + seen = false; + }; + const onBlur = () => { + root.style.opacity = "0"; + seen = false; + }; + + document.addEventListener("pointermove", onMove, { passive: true }); + document.addEventListener("pointerout", onOut, { passive: true }); + window.addEventListener("blur", onBlur); + ensure(); + + return () => { + cancelAnimationFrame(raf); + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerout", onOut); + window.removeEventListener("blur", onBlur); + }; + }, [fine, reduced, count, size, stretch, stiffness, damping]); + + if (!fine) return null; + + return ( +
+ {goo && !reduced && ( + + + + + + + + + )} + {Array.from({ length: count }).map((_, i) => ( +
{ + blobRefs.current[i] = el; + }} + className="absolute left-0 top-0 rounded-full will-change-transform" + style={{ width: diameter(i), height: diameter(i), background: color }} + /> + ))} +
+ ); +} diff --git a/web/components/reactomega/elastic-slider.tsx b/web/components/reactomega/elastic-slider.tsx new file mode 100644 index 0000000..04cd084 --- /dev/null +++ b/web/components/reactomega/elastic-slider.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ElasticSliderProps + extends Omit, "onChange" | "defaultValue" | "children"> { + /** Extra classes for the row that holds the icons and the track. @default undefined */ + className?: string; + /** Controlled value. @default undefined */ + value?: number; + /** Uncontrolled starting value. @default 50 */ + defaultValue?: number; + /** Lower bound. @default 0 */ + min?: number; + /** Upper bound. @default 100 */ + max?: number; + /** Quantisation step. @default 1 */ + step?: number; + /** Fires with every committed value. @default undefined */ + onChange?: (value: number) => void; + /** Maximum rubber-band overshoot in px at either end, 0 disables. @default 56 */ + elasticity?: number; + /** Formats the value for the readout and `aria-valuetext`. @default (v) => String(v) */ + formatValue?: (value: number) => string; + /** Node rendered before the track. @default undefined */ + leftIcon?: React.ReactNode; + /** Node rendered after the track. @default undefined */ + rightIcon?: React.ReactNode; +} + +/** Spring constants for the release snap-back. */ +const SPRING_K = 0.24; +const SPRING_DAMP = 0.7; + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +/** + * ElasticSlider — a slider whose track rubber-bands when you drag past either + * end. Pointer travel beyond the rail is converted to overshoot through + * `limit · (1 - e^(-excess / limit))`, an asymptotic curve: the first pixels + * past the end move almost freely, the last ones barely move at all, and the + * result can never exceed `elasticity` no matter how far you pull — the + * diminishing-returns feel of a real elastic band, which a linear clamp cannot + * produce. The overshoot becomes a `scaleX` on the whole track anchored to the + * opposite end (so the rail genuinely stretches rather than translating), with + * a reciprocal `scaleX` on the thumb to keep it circular. Releasing hands the + * overshoot to a damped spring integrated in `requestAnimationFrame`; the rail + * also swells on the Y axis while the pointer is down. All of that is written + * straight to `style.transform` each frame, while the value itself stays + * ordinary React state, and the rail width is re-read live so the maths + * survives a resizing or scrolling page. + * + * Accessibility: the track carries the full `role="slider"` contract — + * `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext` / + * `aria-orientation` — and is focusable, with Arrow keys stepping, + * PageUp/PageDown taking a larger jump, and Home/End going to the bounds. + * Dragging uses Pointer Events with pointer capture and `touch-action: none`, + * so it works identically with touch, pen and mouse. Reduced motion keeps every + * one of those interactions and simply removes the stretch, the swell and the + * spring. + */ +export function ElasticSlider({ + className, + value, + defaultValue = 50, + min = 0, + max = 100, + step = 1, + onChange, + elasticity = 56, + formatValue, + leftIcon, + rightIcon, + "aria-label": ariaLabel = "Value", + "aria-labelledby": ariaLabelledBy, + style, + ...rest +}: ElasticSliderProps) { + const reduced = usePrefersReducedMotion(); + const trackRef = useRef(null); + const stretchRef = useRef(null); + const railRef = useRef(null); + const thumbRef = useRef(null); + + const over = useRef(0); + const vel = useRef(0); + const swell = useRef(0); + const dragging = useRef(false); + const raf = useRef(0); + + const [internal, setInternal] = useState(defaultValue); + const current = value ?? internal; + + const span = max - min || 1; + const decimals = (() => { + const s = String(step); + const dot = s.indexOf("."); + return dot < 0 ? 0 : s.length - dot - 1; + })(); + const quantize = useCallback( + (v: number) => { + const snapped = Math.round((v - min) / step) * step + min; + return clamp(Number(snapped.toFixed(decimals)), min, max); + }, + [min, max, step, decimals], + ); + + const safe = clamp(current, min, max); + const pct = ((safe - min) / span) * 100; + const readout = formatValue ? formatValue(safe) : String(safe); + + const commit = useCallback( + (raw: number) => { + const next = quantize(raw); + if (value === undefined) setInternal(next); + if (next !== safe) onChange?.(next); + }, + [quantize, value, safe, onChange], + ); + + const paint = useCallback(() => { + const track = trackRef.current; + const stretch = stretchRef.current; + const rail = railRef.current; + const thumb = thumbRef.current; + if (!track || !stretch || !rail || !thumb) return; + // Live width every frame — a cached rect goes stale as the page reflows. + const w = track.getBoundingClientRect().width || 1; + const o = over.current; + const s = 1 + Math.abs(o) / w; + stretch.style.transformOrigin = o > 0 ? "left center" : "right center"; + stretch.style.transform = `scaleX(${s.toFixed(4)})`; + rail.style.transform = `scaleY(${(1 + swell.current * 0.7).toFixed(3)})`; + thumb.style.transform = `translate(-50%,-50%) scaleX(${(1 / s).toFixed(4)}) scale(${(1 + swell.current * 0.14).toFixed(3)})`; + }, []); + + const tick = useCallback(() => { + raf.current = 0; + if (!dragging.current) { + vel.current = (vel.current - over.current * SPRING_K) * SPRING_DAMP; + over.current += vel.current; + if (Math.abs(over.current) < 0.08 && Math.abs(vel.current) < 0.08) { + over.current = 0; + vel.current = 0; + } + } + swell.current += ((dragging.current ? 1 : 0) - swell.current) * 0.22; + if (swell.current < 0.004 && !dragging.current) swell.current = 0; + paint(); + if (dragging.current || over.current !== 0 || swell.current !== 0) { + raf.current = requestAnimationFrame(tick); + } + }, [paint]); + + const ensureLoop = useCallback(() => { + if (reduced) return; + if (!raf.current) raf.current = requestAnimationFrame(tick); + }, [reduced, tick]); + + useEffect(() => () => cancelAnimationFrame(raf.current), []); + + // Keep the rendered thumb geometry correct after value / size changes. + useEffect(() => { + if (reduced) return; + if (!raf.current) paint(); + }, [safe, reduced, paint]); + + const fromClientX = (clientX: number) => { + const track = trackRef.current; + if (!track) return; + const r = track.getBoundingClientRect(); + const w = r.width || 1; + const ratio = (clientX - r.left) / w; + commit(min + clamp(ratio, 0, 1) * span); + + if (reduced || elasticity <= 0) { + over.current = 0; + return; + } + const excess = ratio < 0 ? -ratio * w : ratio > 1 ? (ratio - 1) * w : 0; + const band = elasticity * (1 - Math.exp(-excess / elasticity)); + over.current = ratio < 0 ? -band : ratio > 1 ? band : 0; + }; + + const onPointerDown = (e: React.PointerEvent) => { + if (e.button !== undefined && e.button !== 0) return; + e.preventDefault(); + e.currentTarget.setPointerCapture?.(e.pointerId); + e.currentTarget.focus(); + dragging.current = true; + vel.current = 0; // drop any momentum left over from a previous snap-back + fromClientX(e.clientX); + ensureLoop(); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!dragging.current) return; + fromClientX(e.clientX); + ensureLoop(); + }; + + const endDrag = (e: React.PointerEvent) => { + if (!dragging.current) return; + dragging.current = false; + e.currentTarget.releasePointerCapture?.(e.pointerId); + ensureLoop(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + const big = Math.max(step * 10, span / 10); + let next: number | null = null; + switch (e.key) { + case "ArrowRight": + case "ArrowUp": + next = safe + step; + break; + case "ArrowLeft": + case "ArrowDown": + next = safe - step; + break; + case "PageUp": + next = safe + big; + break; + case "PageDown": + next = safe - big; + break; + case "Home": + next = min; + break; + case "End": + next = max; + break; + default: + return; + } + e.preventDefault(); + commit(clamp(next, min, max)); + }; + + return ( +
+ {leftIcon != null && ( + + {leftIcon} + + )} + +
+
+
+
+
+
+
+
+ + {rightIcon != null && ( + + {rightIcon} + + )} + + {readout} + +
+ ); +} diff --git a/web/components/reactomega/gooey-nav.tsx b/web/components/reactomega/gooey-nav.tsx new file mode 100644 index 0000000..384e15d --- /dev/null +++ b/web/components/reactomega/gooey-nav.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface GooeyNavItem { + /** Visible label. */ + label: string; + /** Stable value — also used as the tab's DOM id so a panel can point at it with `aria-labelledby`. */ + id: string; +} + +export interface GooeyNavProps extends Omit, "onChange" | "defaultValue"> { + /** Extra classes for the nav shell. @default undefined */ + className?: string; + /** The items to render, left to right. @default [] */ + items?: GooeyNavItem[]; + /** Controlled selected id. @default undefined */ + value?: string; + /** Uncontrolled starting id. @default first item's id */ + defaultValue?: string; + /** Fires with the newly selected id. @default undefined */ + onChange?: (id: string) => void; + /** Blob colour. @default "#7c5cff" */ + color?: string; + /** Blur radius fed to the goo filter — higher is more liquid. @default 9 */ + gooStrength?: number; + /** 0–1. How far the trailing blob lags, i.e. how much the blob elongates. @default 0.6 */ + stretch?: number; +} + +/** ms the leading blob takes to reach the new item. */ +const LEAD_MS = 380; + +/** + * GooeyNav — a pill navigation where a liquid indicator flows between items. + * The indicator is two identically coloured blobs sharing one SVG goo filter + * (`feGaussianBlur` → `feColorMatrix` alpha contrast, which re-hardens the + * blurred edges so overlapping shapes fuse). The leading blob reaches the new + * item in `LEAD_MS`; the trailing blob is given a longer duration and a small + * delay scaled by `stretch`, so mid-flight the two are apart and the filter + * renders them as one elongated capsule pointing back the way it came, which + * then settles into a single pill when the trail catches up. A brief scaleY + * squash on the leading blob adds the surface-tension snap. Geometry comes + * from the target button's `offsetLeft` / `offsetWidth` / `offsetTop` / + * `offsetHeight`, re-measured by a `ResizeObserver`, so the blob tracks + * reflows and font swaps without hard-coded sizes. + * + * Accessibility: labels live *outside* the filtered layer (a goo filter would + * smear text), so they stay crisp and selectable. The items are real + * ` + ))} + + ); +} diff --git a/web/components/reactomega/halftone-gradient.tsx b/web/components/reactomega/halftone-gradient.tsx new file mode 100644 index 0000000..3783cf8 --- /dev/null +++ b/web/components/reactomega/halftone-gradient.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface HalftoneGradientProps { + className?: string; + /** First gradient stop. @default "#7c5cff" */ + from?: string; + /** Last gradient stop — the midpoint is derived from both. @default "#4fd1ff" */ + to?: string; + /** Screen pitch in device pixels: dot spacing, or Bayer block size. @default 14 */ + dotSize?: number; + /** Screen angle in degrees. Also sets the gradient direction. @default 22 */ + angle?: number; + /** Drift speed of the gradient underneath the screen. @default 1 */ + speed?: number; + /** Rotated dot screen, or an ordered 4x4 Bayer matrix. @default "dots" */ + mode?: "dots" | "bayer"; +} + +/** + * HalftoneGradient — a drifting multi-stop gradient printed through a screen, + * so it resolves into discrete ink rather than a smooth blend. + * + * The continuous field underneath is a directional ramp perturbed by three + * detuned sines, run through a three-stop ramp (from → a lifted midpoint → to). + * That field is then destroyed on purpose. In "dots" mode each channel gets its + * own rotated dot screen 30° apart, exactly as CMY separations are angled in + * print — the offset is what produces rosettes instead of a moiré clash — and + * dot radius goes as √value so dot *area* stays linear in tone, which is why + * mid-greys do not print muddy. In "bayer" mode a 4×4 ordered dither matrix + * (computed arithmetically, no lookup table) biases a per-channel quantiser to + * four levels, giving flat risograph plates with a visible weave. Antialiasing + * is derived from the cell pitch rather than fwidth so cell seams stay clean. + * The pointer shrinks the local pitch, opening the screen up under the cursor. + */ +export function HalftoneGradient({ + className, + from = "#7c5cff", + to = "#4fd1ff", + dotSize = 14, + angle = 22, + speed = 1, + mode = "dots", +}: HalftoneGradientProps) { + const uniforms = useMemo( + () => ({ + uFrom: hexToRgb(from), + uTo: hexToRgb(to), + uCell: dotSize, + uAngle: (angle * Math.PI) / 180, + uMode: mode === "bayer" ? 1 : 0, + }), + [from, to, dotSize, angle, mode], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +vec2 rot2(vec2 p, float a) { + float c = cos(a); + float s = sin(a); + return vec2(c * p.x - s * p.y, s * p.x + c * p.y); +} + +// 2x2 then 4x4 ordered dither, built from the recursive definition instead of a +// table. Wrapped to 0..3 first so y*y never loses mantissa on tall buffers. +float bayer2(vec2 a) { + a = floor(a); + return fract(a.x * 0.5 + a.y * a.y * 0.75); +} + +float bayer4(vec2 a) { + a = mod(a, 4.0); + return bayer2(a * 0.5) * 0.25 + bayer2(a); +} + +// One rotated dot screen. Radius tracks sqrt(value) so covered *area* is linear +// in tone — the standard print correction. +float screen(vec2 frag, float cell, float ang, float value) { + vec2 q = rot2(frag, ang) / cell; + float d = length(fract(q) - 0.5); + float rad = sqrt(clamp(value, 0.0, 1.0)) * 0.52; + float aa = 1.4 / cell; + return smoothstep(rad + aa, rad - aa, d); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + float t = uTime; + + // Continuous field: a directional ramp with three detuned sines on top, so + // the stops never sit still and never repeat cleanly. + vec2 dir = vec2(cos(uAngle), sin(uAngle)); + float g = dot(uv, dir) * 1.15 + 0.5; + g += 0.20 * sin(uv.y * 3.3 - t * 0.61) + + 0.15 * sin(uv.x * 2.4 + t * 0.47) + + 0.09 * sin((uv.x + uv.y) * 5.1 - t * 0.83); + g = clamp(g, 0.0, 1.0); + + vec3 mid = clamp(mix(uFrom, uTo, 0.5) * 1.32 + 0.05, 0.0, 1.0); + vec3 grad = mix(mix(uFrom, mid, clamp(g * 2.0, 0.0, 1.0)), uTo, clamp(g * 2.0 - 1.0, 0.0, 1.0)); + + // Pointer opens the screen: finer pitch, a touch more ink. + vec2 rel = uv - pc; + float near = uPointer.z * exp(-dot(rel, rel) * 7.0); + float cell = max(uCell * mix(1.0, 0.38, near), 2.0); + + float v = (0.20 + 0.88 * g) * (1.0 - 0.40 * dot(uv, uv)) + 0.20 * near; + vec3 c = clamp(grad * v * 0.98, 0.0, 1.0); + + vec3 ink; + if (uMode > 0.5) { + float th = bayer4(floor(gl_FragCoord.xy / cell)) - 0.5; + ink = floor(c * 4.0 + th + 0.5) * 0.25 * 0.95; + } else { + vec3 cov = vec3(screen(gl_FragCoord.xy, cell, uAngle + 0.2618, c.r), + screen(gl_FragCoord.xy, cell, uAngle + 0.7854, c.g), + screen(gl_FragCoord.xy, cell, uAngle + 1.3090, c.b)); + // Separated screens are what makes a rosette, but taken literally every dot + // lands on a pure primary. Blending back toward shared coverage keeps the + // interference pattern while the colour stays in the gradient. + float shared = dot(cov, vec3(0.3333)); + ink = mix(cov, vec3(shared), 0.62) * grad * 1.02; + } + + vec3 paper = uFrom * 0.035 + vec3(0.016, 0.016, 0.026); + vec3 col = paper + ink; + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.010; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/image-trail.tsx b/web/components/reactomega/image-trail.tsx new file mode 100644 index 0000000..3559405 --- /dev/null +++ b/web/components/reactomega/image-trail.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ImageTrailProps { + /** Image sources dropped along the pointer path, cycled in order. */ + images: string[]; + /** Content rendered underneath the trail. */ + children?: React.ReactNode; + className?: string; + /** Size of the recycled image pool — the most that can be on screen at once. @default 8 */ + count?: number; + /** Px the pointer must travel before the next image is dropped. @default 60 */ + threshold?: number; + /** Lifetime of each image in ms. @default 750 */ + duration?: number; + /** Image width in px; height follows the intrinsic ratio. @default 160 */ + width?: number; + /** Max tilt in degrees, taken from the direction of travel. @default 12 */ + rotation?: number; +} + +/** + * ImageTrail — images fall out of the pointer as it crosses the container, each + * one popping in, tilting into the direction of travel, then fading and shrinking + * away. + * + * The detail that makes this feel right is distance-thresholded emission: nothing + * is dropped per pointermove event. Instead the distance the pointer has travelled + * is banked, and images are dropped at exact multiples of `threshold` measured + * along the path, with the remainder carried into the next event — so one fast + * event can lay down several images in a row and spacing stays exactly `threshold` + * whatever the pointer speed. Event-rate emission would clot on a slow drag and go + * dotted on a flick; even a naive accumulator that resets to the event position + * would round spacing up to a whole multiple of the per-event travel. + * + * Images come from a fixed pool of `count` DOM nodes written round-robin, so a long + * drag never allocates — the oldest image is simply reclaimed. Every position is + * measured against the container's live rect at drop time, so the trail lands + * correctly on a scrolled page, and sources are pre-decoded on mount so the first + * pass does not flicker. The loop idles when the last image dies. Reduced motion + * and coarse pointers drop nothing at all; the children render untouched. + */ +export function ImageTrail({ + images, + children, + className, + count = 8, + threshold = 60, + duration = 750, + width = 160, + rotation = 12, +}: ImageTrailProps) { + const wrapRef = useRef(null); + const itemRefs = useRef<(HTMLImageElement | null)[]>([]); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + // Warm the cache so the first lap of the pool does not pop in blank. + useEffect(() => { + if (typeof window === "undefined") return; + for (const src of images) { + const img = new Image(); + img.src = src; + } + }, [images]); + + useEffect(() => { + if (!fine || reduced || !images.length) return; + const wrap = wrapRef.current; + if (!wrap) return; + const items = itemRefs.current.slice(0, count).filter(Boolean) as HTMLImageElement[]; + if (!items.length) return; + + const slots = items.map(() => ({ born: -1, x: 0, y: 0, deg: 0 })); + const gap = Math.max(1, threshold); // a zero threshold would divide by zero + let head = 0; + let imageIndex = 0; + let lastX = 0; + let lastY = 0; + let acc = 0; // distance banked since the last drop, remainder carried forward + let primed = false; + let raf = 0; + let running = false; + + const step = () => { + const now = performance.now(); + let alive = false; + + for (let i = 0; i < items.length; i++) { + const s = slots[i]; + if (s.born < 0) continue; + const p = (now - s.born) / duration; + if (p >= 1) { + s.born = -1; + items[i].style.opacity = "0"; + continue; + } + alive = true; + // Quick pop in, long hold, accelerating fade out. + const rise = Math.min(1, p / 0.09); + const scale = (0.82 + 0.18 * rise) * (1 - 0.18 * p); + items[i].style.opacity = (rise * (1 - p * p)).toFixed(3); + items[i].style.transform = + `translate3d(${s.x.toFixed(1)}px, ${s.y.toFixed(1)}px, 0)` + + ` translate(-50%, -50%) rotate(${s.deg.toFixed(2)}deg) scale(${scale.toFixed(3)})`; + } + + if (alive) raf = requestAnimationFrame(step); + else running = false; + }; + + const ensure = () => { + if (!running) { + running = true; + raf = requestAnimationFrame(step); + } + }; + + const emit = (x: number, y: number, dx: number) => { + const i = head % items.length; + head++; + const s = slots[i]; + s.born = performance.now(); + s.x = x; + s.y = y; + // Lean into the swipe: dx over one threshold of travel maps to full tilt. + s.deg = Math.max(-rotation, Math.min(rotation, (dx / gap) * rotation)); + items[i].src = images[imageIndex % images.length]; + imageIndex++; + items[i].style.zIndex = String(head); + ensure(); + }; + + const onMove = (e: PointerEvent) => { + // Live rect — the page may have scrolled since the last event. + const r = wrap.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (!primed) { + primed = true; + lastX = x; + lastY = y; + acc = 0; + return; + } + const dx = x - lastX; + const dy = y - lastY; + const seg = Math.hypot(dx, dy); + if (seg > 0) { + const banked = acc; // travelled before this segment began + acc += seg; + // Drop at exact multiples of `threshold` measured along the path, and + // carry the remainder. Resetting the accumulator to the event position + // instead would stretch spacing to a whole multiple of the per-event + // travel, so a fast flick would space images further apart than a slow + // drag — the speed dependence this whole approach exists to remove. + const drops = Math.min(items.length, Math.floor(acc / gap)); + let along = gap - banked; + for (let n = 0; n < drops; n++) { + const t = Math.max(0, Math.min(1, along / seg)); + emit(lastX + dx * t, lastY + dy * t, dx); + along += threshold; + } + acc %= gap; + } + lastX = x; + lastY = y; + }; + const onEnter = (e: PointerEvent) => { + const r = wrap.getBoundingClientRect(); + lastX = e.clientX - r.left; + lastY = e.clientY - r.top; + acc = 0; + primed = true; + }; + const onLeave = () => { + primed = false; + }; + + wrap.addEventListener("pointerenter", onEnter); + wrap.addEventListener("pointermove", onMove, { passive: true }); + wrap.addEventListener("pointerleave", onLeave); + + return () => { + cancelAnimationFrame(raf); + wrap.removeEventListener("pointerenter", onEnter); + wrap.removeEventListener("pointermove", onMove); + wrap.removeEventListener("pointerleave", onLeave); + for (const el of items) el.style.opacity = "0"; + }; + }, [fine, reduced, images, count, threshold, duration, rotation]); + + return ( +
+ {children} +
+ {Array.from({ length: count }).map((_, i) => ( + /* eslint-disable-next-line @next/next/no-img-element */ + { + itemRefs.current[i] = el; + }} + alt="" + aria-hidden + draggable={false} + decoding="async" + className="absolute left-0 top-0 select-none rounded-lg object-cover will-change-transform" + style={{ width, height: "auto", opacity: 0 }} + /> + ))} +
+
+ ); +} diff --git a/web/components/reactomega/inertia-cursor.tsx b/web/components/reactomega/inertia-cursor.tsx new file mode 100644 index 0000000..f820be7 --- /dev/null +++ b/web/components/reactomega/inertia-cursor.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface InertiaCursorProps { + className?: string; + /** Diameter of the precise inner dot in px. @default 7 */ + size?: number; + /** Diameter of the lagging outer ring in px. @default 34 */ + ringSize?: number; + /** Ring spring acceleration, 0..1 — higher catches up faster. @default 0.18 */ + stiffness?: number; + /** Ring spring damping, 0..1 — lower kills the bounce harder. @default 0.62 */ + damping?: number; + /** Ring scale while hovering an interactive target. @default 1.7 */ + hoverScale?: number; + /** Selector the ring reacts to, tested with closest() on whatever is hovered. @default "a, button, [data-cursor]" */ + selector?: string; + /** Cursor color. Painted with mix-blend-mode difference, so white inverts whatever sits behind it. @default "#ffffff" */ + color?: string; + /** Hide the native cursor while this one is on screen. @default true */ + hideNative?: boolean; +} + +/** + * InertiaCursor — a two-part cursor replacement: a small dot pinned exactly to + * the pointer, and a larger ring that trails behind it on a spring, so fast + * flicks stretch the pair apart and a stop lets the ring coast in. + * + * Hover state is delegated from `document` via pointerover, so the ring reacts + * to elements that were added to the DOM long after mount, and a scroll listener + * re-tests the node under the cursor for the case where the page moves but the + * mouse does not. Both halves are painted with `mix-blend-mode: difference`, + * which keeps them readable over any background without knowing anything about + * it. Positions are resolved against the overlay's live rect each frame so the + * cursor stays true even if an ancestor transform captures the fixed container. + * + * The rAF loop idles as soon as the ring and the hover spring have settled and + * restarts on the next pointer event. Coarse pointers render nothing at all — + * a lagging ring on a touchscreen is just a stuck artifact. Reduced motion drops + * the spring and the scale: the ring simply follows. + */ +export function InertiaCursor({ + className, + size = 7, + ringSize = 34, + stiffness = 0.18, + damping = 0.62, + hoverScale = 1.7, + selector = "a, button, [data-cursor]", + color = "#ffffff", + hideNative = true, +}: InertiaCursorProps) { + const rootRef = useRef(null); + const dotRef = useRef(null); + const ringRef = useRef(null); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + + // Only mouse-like pointers get a cursor replacement. + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + // Swap out the native cursor for ours, and put it back exactly as we found it. + useEffect(() => { + if (!fine || !hideNative) return; + const style = document.createElement("style"); + style.textContent = ".ro-cursor-none, .ro-cursor-none * { cursor: none !important; }"; + document.head.appendChild(style); + document.documentElement.classList.add("ro-cursor-none"); + return () => { + document.documentElement.classList.remove("ro-cursor-none"); + style.remove(); + }; + }, [fine, hideNative]); + + useEffect(() => { + if (!fine) return; + const root = rootRef.current; + const dot = dotRef.current; + const ring = ringRef.current; + if (!root || !dot || !ring) return; + + // An invalid selector would throw on every single pointerover, so test it once. + let safeSelector = selector; + try { + document.querySelector(selector); + } catch { + safeSelector = ""; + } + + // Viewport coords of the pointer; ring position and hover amount are sprung. + let px = -9999; + let py = -9999; + let rx = -9999; + let ry = -9999; + let vx = 0; + let vy = 0; + let hover = 0; + let hoverV = 0; + let hoverTarget = 0; + let seen = false; + let raf = 0; + let running = false; + + const step = () => { + // Live rect: if an ancestor transform has captured our fixed container, + // this keeps the cursor glued to the real pointer anyway. + const r = root.getBoundingClientRect(); + + if (reduced) { + rx = px; + ry = py; + vx = 0; + vy = 0; + hover = hoverTarget; + hoverV = 0; + } else { + vx = (vx + (px - rx) * stiffness) * damping; + vy = (vy + (py - ry) * stiffness) * damping; + rx += vx; + ry += vy; + hoverV = (hoverV + (hoverTarget - hover) * stiffness) * damping; + hover += hoverV; + } + + const scale = 1 + (hoverScale - 1) * hover; + dot.style.transform = `translate3d(${(px - r.left).toFixed(2)}px, ${(py - r.top).toFixed(2)}px, 0) translate(-50%, -50%)`; + ring.style.transform = `translate3d(${(rx - r.left).toFixed(2)}px, ${(ry - r.top).toFixed(2)}px, 0) translate(-50%, -50%) scale(${scale.toFixed(3)})`; + ring.style.opacity = (0.55 + 0.45 * Math.max(0, Math.min(1, hover))).toFixed(3); + + const moving = + Math.abs(px - rx) > 0.05 || + Math.abs(py - ry) > 0.05 || + Math.abs(vx) > 0.05 || + Math.abs(vy) > 0.05 || + Math.abs(hoverTarget - hover) > 0.002 || + Math.abs(hoverV) > 0.002; + + if (moving) raf = requestAnimationFrame(step); + else running = false; + }; + + const ensure = () => { + if (!running) { + running = true; + raf = requestAnimationFrame(step); + } + }; + + const testHover = (node: EventTarget | Element | null) => { + if (!safeSelector) return; + hoverTarget = node instanceof Element && node.closest(safeSelector) ? 1 : 0; + }; + + const onMove = (e: PointerEvent) => { + px = e.clientX; + py = e.clientY; + if (!seen) { + // First sighting: drop the ring on the pointer instead of flying it in + // from the corner, then fade the pair up. + seen = true; + rx = px; + ry = py; + root.style.opacity = "1"; + } + ensure(); + }; + const onOver = (e: PointerEvent) => { + testHover(e.target); + ensure(); + }; + const onScroll = () => { + if (!seen) return; + testHover(document.elementFromPoint(px, py)); + ensure(); + }; + const onLeave = (e: PointerEvent) => { + if (e.relatedTarget) return; + root.style.opacity = "0"; + seen = false; + hoverTarget = 0; + ensure(); + }; + const onBlur = () => { + root.style.opacity = "0"; + seen = false; + }; + + document.addEventListener("pointermove", onMove, { passive: true }); + document.addEventListener("pointerover", onOver, { passive: true }); + document.addEventListener("pointerout", onLeave, { passive: true }); + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("blur", onBlur); + ensure(); + + return () => { + cancelAnimationFrame(raf); + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerover", onOver); + document.removeEventListener("pointerout", onLeave); + window.removeEventListener("scroll", onScroll); + window.removeEventListener("blur", onBlur); + }; + }, [fine, reduced, stiffness, damping, hoverScale, selector]); + + if (!fine) return null; + + return ( +
+
+
+
+ ); +} diff --git a/web/components/reactomega/liquid-metal.tsx b/web/components/reactomega/liquid-metal.tsx new file mode 100644 index 0000000..b7376d2 --- /dev/null +++ b/web/components/reactomega/liquid-metal.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface LiquidMetalProps { + className?: string; + /** Highlight tint of the polished surface. @default "#c9d4ff" */ + tint?: string; + /** Colour in the troughs. @default "#0a0b14" */ + shadow?: string; + /** Zoom of the flow pattern — higher is busier. @default 1.5 */ + scale?: number; + /** Flow speed multiplier. @default 1 */ + speed?: number; + /** Number of polish bands swept across the surface. @default 5 */ + bands?: number; + /** How hard the pointer pushes into the metal, 0..1. @default 0.6 */ + push?: number; +} + +/** + * LiquidMetal — a molten chrome surface that flows, catches light and dents + * away from the pointer. + * + * The height field is two rounds of domain-warped value-noise FBM; the surface + * normal is taken from finite differences of that field each frame, then lit + * with a diffuse term, a tight specular, and a swept band pattern derived from + * the normal — which is what reads as *polished* rather than merely bumpy. + * Runs on raw WebGL2 with no dependencies, and settles into a single still + * frame for reduced-motion users. + */ +export function LiquidMetal({ + className, + tint = "#c9d4ff", + shadow = "#0a0b14", + scale = 1.5, + speed = 1, + bands = 5, + push = 0.6, +}: LiquidMetalProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uShadow: hexToRgb(shadow), + uScale: scale, + uBands: bands, + uPush: push, + }), + [tint, shadow, scale, bands, push], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float a = 0.5; + float v = 0.0; + for (int i = 0; i < 4; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +// Two rounds of domain warping — the second warp is what gives the metal its +// stretched, poured look instead of generic cloud noise. +float field(vec2 p, float t) { + vec2 q = vec2(fbm(p + vec2(0.0, t * 0.20)), fbm(p + vec2(5.2, 1.3))); + vec2 r = vec2(fbm(p + 2.1 * q + vec2(1.7, 9.2) - t * 0.15), + fbm(p + 2.1 * q + vec2(8.3, 2.8) + t * 0.12)); + return fbm(p + 1.7 * r); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + // Pointer dents the surface: push the sample point radially away from it. + vec2 away = uv - pc; + float d2 = dot(away, away); + float dent = uPointer.z * uPush * exp(-d2 * 7.0); + vec2 p = uv * uScale + normalize(away + 1e-5) * dent; + + float t = uTime * 0.4; + // Wide finite-difference stencil on purpose: sampling the noise too tightly + // returns per-pixel normals, which read as grey static rather than poured metal. + float e = 0.030; + float h = field(p, t); + vec3 n = normalize(vec3(field(p + vec2(e, 0.0), t) - h, field(p + vec2(0.0, e), t) - h, 0.09)); + + vec3 L = normalize(vec3(0.45, 0.72, 0.62)); + float diff = max(dot(n, L), 0.0); + float spec = pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 42.0); + + // Swept polish bands read off the normal, not the height — this is the + // difference between "chrome" and "grey clouds". + float sweep = 0.5 + 0.5 * sin(n.x * uBands * 1.1 + n.y * uBands * 0.8 + h * uBands * 0.7 - t * 0.9); + vec3 base = mix(uShadow, uTint, smoothstep(0.10, 0.95, sweep)); + + vec3 col = base * (0.30 + 0.90 * diff) + spec * 1.35; + col += (uPointer.z * 0.10) * exp(-d2 * 9.0) * uTint; + + // Dither out 8-bit banding in the dark falloff. + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + col *= 1.0 - 0.55 * dot(uv, uv); + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/parallax-layers.tsx b/web/components/reactomega/parallax-layers.tsx new file mode 100644 index 0000000..9ec4627 --- /dev/null +++ b/web/components/reactomega/parallax-layers.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ParallaxLayersProps { + children: React.ReactNode; + /** Extra classes for the section. @default undefined */ + className?: string; + /** Drift of a `depth={1}` layer at the extremes of the pass, in px. @default 120 */ + range?: number; + /** Axis the layers drift along. @default "y" */ + axis?: "y" | "x"; + /** Hold the drift at its extremes once the section has left the viewport. @default true */ + clamp?: boolean; +} + +export interface ParallaxLayerProps { + children: React.ReactNode; + /** Extra classes for the layer. @default undefined */ + className?: string; + /** 0 is locked to the page, 1 drifts the full `range`. Values above 1 are allowed. @default 0.3 */ + depth?: number; + /** Extra scale at `depth={1}`, e.g. 0.08 to over-size drifting layers so no edge is exposed. @default 0 */ + scale?: number; + /** Blur at `depth={1}`, in px, scaled by depth. 0 disables the filter entirely. @default 0 */ + blur?: number; +} + +interface Registration { + el: HTMLElement; + depth: number; + scale: number; + blur: number; +} + +const ParallaxContext = createContext<{ register: (r: Registration) => () => void } | null>(null); + +/** + * ParallaxLayers — a section whose children drift at different rates as it passes through + * the viewport, so nearer things outrun farther ones and the band reads as having depth. + * + * Progress is signed and derived from the container's own position rather than raw + * `scrollY`: `(viewportCentre - sectionCentre) / ((viewportHeight + sectionHeight) / 2)`. + * That is exactly -1 when the section is about to enter, **0 when the section is centred**, + * and +1 when it has just left — so the layers sit at their neutral, as-authored positions + * at the moment the reader is actually looking at them, and the effect never depends on + * where the section happens to live in the document. + * + * Layers register their DOM node with the container through context rather than receiving + * progress as a prop, so the scroll loop writes `transform` straight to each node and the + * React tree never re-renders while scrolling. The container reads one rect per frame, + * coalesced from a passive scroll listener via a dirty animation frame, and re-measures + * through a ResizeObserver. + * + * Reduced-motion users get every layer at its neutral position in normal flow — nothing is + * displaced, blurred or scaled. + */ +export function ParallaxLayers({ + children, + className, + range = 120, + axis = "y", + clamp = true, +}: ParallaxLayersProps) { + const ref = useRef(null); + const registry = useRef>(new Set()); + const requestRef = useRef<() => void>(() => {}); + const reduced = usePrefersReducedMotion(); + + const register = useCallback((entry: Registration) => { + registry.current.add(entry); + requestRef.current(); + return () => { + registry.current.delete(entry); + entry.el.style.transform = ""; + entry.el.style.filter = ""; + }; + }, []); + + const ctx = useMemo(() => ({ register }), [register]); + + useEffect(() => { + const clear = () => { + registry.current.forEach((entry) => { + entry.el.style.transform = ""; + entry.el.style.filter = ""; + }); + }; + + if (reduced) { + clear(); + return; + } + + const el = ref.current; + if (!el) return; + + let raf = 0; + + const update = () => { + raf = 0; + const rect = el.getBoundingClientRect(); + const vh = window.innerHeight || 1; + const span = (vh + rect.height) / 2; + if (span <= 0) return; + + let p = (vh / 2 - (rect.top + rect.height / 2)) / span; + if (clamp) p = p < -1 ? -1 : p > 1 ? 1 : p; + + registry.current.forEach((entry) => { + const shift = p * range * entry.depth; + const move = + axis === "x" + ? `translate3d(${shift.toFixed(2)}px, 0, 0)` + : `translate3d(0, ${shift.toFixed(2)}px, 0)`; + const s = 1 + entry.depth * entry.scale; + entry.el.style.transform = entry.scale !== 0 ? `${move} scale(${s.toFixed(4)})` : move; + if (entry.blur > 0) entry.el.style.filter = `blur(${(entry.depth * entry.blur).toFixed(2)}px)`; + }); + }; + + const request = () => { + if (!raf) raf = requestAnimationFrame(update); + }; + + requestRef.current = request; + update(); + window.addEventListener("scroll", request, { passive: true }); + window.addEventListener("resize", request); + const ro = new ResizeObserver(request); + ro.observe(el); + + return () => { + cancelAnimationFrame(raf); + requestRef.current = () => {}; + window.removeEventListener("scroll", request); + window.removeEventListener("resize", request); + ro.disconnect(); + clear(); + }; + }, [reduced, range, axis, clamp]); + + return ( + +
+ {children} +
+
+ ); +} + +/** + * ParallaxLayer — one plane inside a `ParallaxLayers` section. It registers its node with + * the parent on mount and is driven imperatively from there, so changing `depth` re-registers + * and takes effect on the next frame. Used outside a `ParallaxLayers` it renders as a plain + * block with no motion, which is also exactly what reduced-motion users get. + */ +export function ParallaxLayer({ children, className, depth = 0.3, scale = 0, blur = 0 }: ParallaxLayerProps) { + const ref = useRef(null); + const ctx = useContext(ParallaxContext); + + useEffect(() => { + const el = ref.current; + if (!el || !ctx) return; + return ctx.register({ el, depth, scale, blur }); + }, [ctx, depth, scale, blur]); + + return ( +
+ {children} +
+ ); +} diff --git a/web/components/reactomega/pixel-trail.tsx b/web/components/reactomega/pixel-trail.tsx new file mode 100644 index 0000000..8e17dd7 --- /dev/null +++ b/web/components/reactomega/pixel-trail.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface PixelTrailProps { + className?: string; + /** Edge length of one cell in px. @default 12 */ + pixelSize?: number; + /** Lit pixel color. @default "#7c5cff" */ + color?: string; + /** Brightness kept per frame, 0..1 — higher leaves a longer comet. @default 0.9 */ + decay?: number; + /** Bloom strength around lit pixels; 0 disables it. @default 0.6 */ + glow?: number; + /** Space between cells in px. @default 2 */ + gap?: number; +} + +/** + * PixelTrail — a grid of square cells that ignite to full brightness as the + * pointer crosses them and then decay exponentially, leaving a dissolving comet + * of pixels behind the cursor. + * + * Cells are lit by walking the segment between the previous and current pointer + * position at half-cell steps rather than by lighting whatever sits under the + * latest event. A mouse reporting at 60Hz can travel hundreds of px between two + * events, and per-event lighting turns a fast flick into a dotted line; segment + * interpolation keeps the path continuous at any speed, with the sample count + * capped so a window-wide jump cannot spike a frame. + * + * Brightness lives in a single Float32Array multiplied by `decay` each frame — + * at the default that is a ~110ms half life. Only cells above the visible + * threshold are painted, in two passes (an additive bloom pass, then the crisp + * cores) so the composite mode is set twice per frame instead of twice per pixel. + * The loop stops the moment the grid is dark and restarts on the next move. + * Reduced motion and coarse pointers get the same grid rendered once, unlit and + * static, so the surface still reads as a textured panel. + */ +export function PixelTrail({ + className, + pixelSize = 12, + color = "#7c5cff", + decay = 0.9, + glow = 0.6, + gap = 2, +}: PixelTrailProps) { + const ref = useRef(null); + const [fine, setFine] = useState(false); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: fine)"); + const update = () => setFine(mq.matches); + update(); + mq.addEventListener?.("change", update); + return () => mq.removeEventListener?.("change", update); + }, []); + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const cell = Math.max(1, pixelSize); + const step = cell + Math.max(0, gap); + const fade = Math.min(0.985, Math.max(0.5, decay)); + const still = reduced || !fine; + + let w = 0; + let h = 0; + let cols = 0; + let rows = 0; + let buf = new Float32Array(0); + let raf = 0; + let running = false; + let lastX = -1; + let lastY = -1; + + const paintStatic = () => { + ctx.clearRect(0, 0, w, h); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 0.1; + ctx.fillStyle = color; + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) ctx.fillRect(x * step, y * step, cell, cell); + } + ctx.globalAlpha = 1; + }; + + const resize = () => { + const r = canvas.getBoundingClientRect(); + w = Math.max(1, r.width); + h = Math.max(1, r.height); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + cols = Math.max(1, Math.ceil(w / step)); + rows = Math.max(1, Math.ceil(h / step)); + buf = new Float32Array(cols * rows); + if (still) paintStatic(); + }; + + const draw = () => { + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = color; + + // Pass 1 — additive bloom, laid down under the cores. + if (glow > 0) { + ctx.globalCompositeOperation = "lighter"; + const pad = cell * 0.55; + for (let i = 0; i < buf.length; i++) { + const v = buf[i]; + if (v < 0.02) continue; + ctx.globalAlpha = Math.min(1, v * 0.3 * glow); + ctx.fillRect((i % cols) * step - pad, Math.floor(i / cols) * step - pad, cell + pad * 2, cell + pad * 2); + } + } + + // Pass 2 — crisp cores, plus the decay sweep for every cell. + ctx.globalCompositeOperation = "source-over"; + let lit = 0; + for (let i = 0; i < buf.length; i++) { + const v = buf[i]; + if (v < 0.02) { + if (v !== 0) buf[i] = 0; + continue; + } + lit++; + ctx.globalAlpha = Math.min(1, v); + ctx.fillRect((i % cols) * step, Math.floor(i / cols) * step, cell, cell); + buf[i] = v * fade; + } + + ctx.globalAlpha = 1; + if (lit > 0) raf = requestAnimationFrame(draw); + else running = false; + }; + + const ensure = () => { + if (still || running) return; + running = true; + raf = requestAnimationFrame(draw); + }; + + const ignite = (x0: number, y0: number, x1: number, y1: number) => { + const dist = Math.hypot(x1 - x0, y1 - y0); + const samples = Math.min(240, Math.max(1, Math.ceil(dist / (step * 0.5)))); + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const cx = Math.floor((x0 + (x1 - x0) * t) / step); + const cy = Math.floor((y0 + (y1 - y0) * t) / step); + if (cx < 0 || cy < 0 || cx >= cols || cy >= rows) continue; + buf[cy * cols + cx] = 1; + } + }; + + const onMove = (e: PointerEvent) => { + if (still) return; + const r = canvas.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + if (lastX < 0) ignite(x, y, x, y); + else ignite(lastX, lastY, x, y); + lastX = x; + lastY = y; + ensure(); + }; + // Break the path on both enter and leave so re-entering the panel does not + // draw a streak across it from wherever the pointer left. + const breakPath = () => { + lastX = -1; + lastY = -1; + }; + + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas); + canvas.addEventListener("pointermove", onMove, { passive: true }); + canvas.addEventListener("pointerenter", breakPath); + canvas.addEventListener("pointerleave", breakPath); + + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + canvas.removeEventListener("pointermove", onMove); + canvas.removeEventListener("pointerenter", breakPath); + canvas.removeEventListener("pointerleave", breakPath); + }; + }, [pixelSize, color, decay, glow, gap, reduced, fine]); + + return ; +} diff --git a/web/components/reactomega/scroll-scene.tsx b/web/components/reactomega/scroll-scene.tsx new file mode 100644 index 0000000..cfc9563 --- /dev/null +++ b/web/components/reactomega/scroll-scene.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ScrollSceneProps { + /** Render prop. Receives the scrub progress: 0 the moment the scene pins, 1 when it unpins. */ + children: (progress: number) => React.ReactNode; + /** Extra classes for the pinned viewport — the visible surface, not the tall outer spacer. @default undefined */ + className?: string; + /** Extra viewports of scroll to pin for. 1 means one full screen of scrubbing. @default 1.5 */ + length?: number; + /** Progress handed to children when the user prefers reduced motion. @default 1 */ + staticProgress?: number; + /** Inertia on the scrub, 0 to 1. 0 tracks scroll exactly; higher glides in behind it. @default 0.72 */ + smoothing?: number; + /** Called every frame with the raw, unrounded progress. The zero-re-render escape hatch. @default undefined */ + onProgress?: (progress: number) => void; +} + +/** + * ScrollScene — pins a section for a set distance and hands its children the scrub + * progress, so anything at all can be driven by scroll position. + * + * A tall outer spacer (`100vh * (1 + length)`) holds the scroll distance; inside it a + * `position: sticky; top: 0` viewport does the pinning natively, which is smoother than + * a JS-driven fixed element and does not fight iOS momentum scrolling. Progress is + * `-outer.top / (outer.height - pinned.height)` — deriving the travel from the two + * measured heights rather than assuming `innerHeight` is what keeps it exact, because the + * pin genuinely ends when the sticky element's bottom meets the spacer's bottom, and mobile + * `100vh` is not the visible viewport. Off-by-one-viewport is the classic bug here and this + * form has no viewport constant in it at all. + * + * Three ways out, in increasing cost: the pinned element carries a live `--ro-progress` + * custom property (free, pure CSS); `onProgress` fires with the raw value every frame (no + * re-render); and the render prop re-renders, but only on a change of 1/1000, so a frame + * that does not move the value does not cost a render. Smoothing is a frame-rate-independent + * exponential (`1 - smoothing^(dt*60)`), and the loop stops itself once it has settled. + * + * Reduced motion removes the spacer and the pin entirely: the section takes its natural + * height in normal flow and the children are rendered once at `staticProgress`, so nothing + * is ever left off-screen or invisible. Keep focusable elements visible across the whole + * range — a link that only exists at progress 0.5 is a keyboard trap. + */ +export function ScrollScene({ + children, + className, + length = 1.5, + staticProgress = 1, + smoothing = 0.72, + onProgress, +}: ScrollSceneProps) { + const outerRef = useRef(null); + const pinRef = useRef(null); + const callbackRef = useRef(onProgress); + const [progress, setProgress] = useState(0); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + callbackRef.current = onProgress; + }); + + useEffect(() => { + if (!reduced) return; + pinRef.current?.style.setProperty("--ro-progress", staticProgress.toFixed(5)); + callbackRef.current?.(staticProgress); + }, [reduced, staticProgress]); + + useEffect(() => { + if (reduced) return; + const outer = outerRef.current; + const pin = pinRef.current; + if (!outer || !pin) return; + + const inertia = smoothing < 0 ? 0 : smoothing > 0.99 ? 0.99 : smoothing; + let raf = 0; + let running = false; + let dirty = false; + let primed = false; + let last = 0; + let current = 0; + let target = 0; + + const measure = () => { + const outerRect = outer.getBoundingClientRect(); + const travel = outerRect.height - pin.getBoundingClientRect().height; + if (travel <= 0) { + target = outerRect.top <= 0 ? 1 : 0; + return; + } + const p = -outerRect.top / travel; + target = p < 0 ? 0 : p > 1 ? 1 : p; + }; + + const commit = (value: number) => { + pin.style.setProperty("--ro-progress", value.toFixed(5)); + callbackRef.current?.(value); + const quantised = Math.round(value * 1000) / 1000; + setProgress((prev) => (prev === quantised ? prev : quantised)); + }; + + const step = (now: number) => { + // Layout reads happen here, never in the scroll handler. + if (dirty) { + dirty = false; + measure(); + } + const dt = primed ? Math.min(0.05, (now - last) / 1000) : 1 / 60; + last = now; + primed = true; + + const k = inertia > 0 ? 1 - Math.pow(inertia, dt * 60) : 1; + current += (target - current) * k; + if (Math.abs(target - current) < 0.0002) current = target; + commit(current); + + if (current !== target || dirty) { + raf = requestAnimationFrame(step); + } else { + running = false; + raf = 0; + } + }; + + const ensure = () => { + if (running) return; + running = true; + primed = false; + raf = requestAnimationFrame(step); + }; + + const invalidate = () => { + dirty = true; + ensure(); + }; + + measure(); + current = target; + commit(current); + + window.addEventListener("scroll", invalidate, { passive: true }); + window.addEventListener("resize", invalidate); + const ro = new ResizeObserver(invalidate); + ro.observe(outer); + ro.observe(pin); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("scroll", invalidate); + window.removeEventListener("resize", invalidate); + ro.disconnect(); + }; + }, [reduced, smoothing]); + + const value = reduced ? staticProgress : progress; + + return ( +
+
+ {children(value)} +
+ {!reduced && ( + + )} +
+ ); +} diff --git a/web/components/reactomega/scroll-stack.tsx b/web/components/reactomega/scroll-stack.tsx new file mode 100644 index 0000000..3bf1ce4 --- /dev/null +++ b/web/components/reactomega/scroll-stack.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Children, useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ScrollStackProps { + children: React.ReactNode; + /** Extra classes for the stack container. @default undefined */ + className?: string; + /** Distance from the top of the viewport that each card pins at, in px. @default 80 */ + offset?: number; + /** Scale removed per card resting on top of this one. @default 0.04 */ + scaleStep?: number; + /** Opacity removed per card resting on top of this one. @default 0.14 */ + opacityStep?: number; + /** Blur added per card resting on top of this one, in px. 0 disables the filter entirely. @default 2 */ + blur?: number; + /** Vertical space between cards while they are still in flow, in px. @default 64 */ + gap?: number; +} + +/** Cards deeper than this stop receding, so a long list can never invert the scale. */ +const MAX_DEPTH = 4; + +const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n); + +/** + * ScrollStack — cards that collect into a deck as you scroll. Every card is pinned + * with native `position: sticky` (never JS-driven fixed positioning, so it survives + * iOS momentum scrolling), and each one recedes by the number of cards already + * resting on top of it. + * + * The depth of a card is the running sum of how far every later card has travelled + * to its rest position, measured live from the sticky wrappers' own rects — so it is + * fractional and continuous rather than stepped. A receding card scales from its top + * edge and lifts by exactly the height it loses (`height * depth * scaleStep`), which + * pushes its top edge above the card in front by that same amount and produces the + * fanned deck without any magic peek constant. The last card has nothing on top of it, + * so it always renders clean. + * + * The transform lives on an inner element and the rect is read from the sticky wrapper, + * so the measurement can never be contaminated by the transform it produces. Reads for + * every card happen in one pass before any write, and scroll events only raise a dirty + * flag that is drained on the next animation frame. + * + * Give each child an opaque background — they overlap by design. Reduced-motion users + * get the same cards unpinned, unscaled and in normal document flow. + */ +export function ScrollStack({ + children, + className, + offset = 80, + scaleStep = 0.04, + opacityStep = 0.14, + blur = 2, + gap = 64, +}: ScrollStackProps) { + const containerRef = useRef(null); + const wrapRefs = useRef<(HTMLDivElement | null)[]>([]); + const innerRefs = useRef<(HTMLDivElement | null)[]>([]); + const reduced = usePrefersReducedMotion(); + const items = Children.toArray(children); + + useEffect(() => { + const inners = innerRefs.current; + const reset = () => { + inners.forEach((el) => { + if (!el) return; + el.style.transform = ""; + el.style.opacity = ""; + el.style.filter = ""; + }); + }; + + if (reduced) { + reset(); + return; + } + + const container = containerRef.current; + if (!container) return; + + let raf = 0; + + const update = () => { + raf = 0; + const wraps = wrapRefs.current; + const n = wraps.length; + if (!n) return; + + // Read pass — every rect first, so no write can force an extra layout. + const rects: (DOMRect | null)[] = new Array(n); + for (let i = 0; i < n; i++) { + const w = wraps[i]; + rects[i] = w ? w.getBoundingClientRect() : null; + } + + // How far each card has travelled from "sitting below its predecessor" to + // "landed on the stack". 0 when its top is one gap below the previous card's + // bottom, 1 when the two tops coincide at the pin line. + const arrival: number[] = new Array(n).fill(0); + for (let j = 1; j < n; j++) { + const prev = rects[j - 1]; + const cur = rects[j]; + if (!prev || !cur) continue; + const travel = prev.height + gap; + if (travel <= 0) continue; + arrival[j] = clamp01((prev.bottom + gap - cur.top) / travel); + } + + // Write pass — depth of card i is the sum of every arrival after it. + let acc = 0; + for (let i = n - 1; i >= 0; i--) { + const inner = inners[i]; + const rect = rects[i]; + const depth = acc < MAX_DEPTH ? acc : MAX_DEPTH; + acc += arrival[i]; + if (!inner) continue; + + if (depth < 0.001) { + inner.style.transform = ""; + inner.style.opacity = ""; + if (blur > 0) inner.style.filter = ""; + continue; + } + + const scale = 1 - depth * scaleStep; + const lift = depth * (rect ? rect.height : 0) * scaleStep; + inner.style.transform = `translate3d(0, ${(-lift).toFixed(2)}px, 0) scale(${scale.toFixed(4)})`; + inner.style.opacity = Math.max(0, 1 - depth * opacityStep).toFixed(3); + if (blur > 0) inner.style.filter = `blur(${(depth * blur).toFixed(2)}px)`; + } + }; + + const request = () => { + if (!raf) raf = requestAnimationFrame(update); + }; + + update(); + window.addEventListener("scroll", request, { passive: true }); + window.addEventListener("resize", request); + const ro = new ResizeObserver(request); + ro.observe(container); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("scroll", request); + window.removeEventListener("resize", request); + ro.disconnect(); + reset(); + }; + }, [reduced, offset, scaleStep, opacityStep, blur, gap, items.length]); + + return ( +
+ {items.map((child, i) => ( +
{ + wrapRefs.current[i] = el; + }} + className={reduced ? "relative" : "sticky"} + style={reduced ? undefined : { top: `${offset}px` }} + > +
{ + innerRefs.current[i] = el; + }} + className={cn("origin-top", !reduced && "will-change-transform")} + > + {child} +
+
+ ))} +
+ ); +} diff --git a/web/components/reactomega/scroll-velocity.tsx b/web/components/reactomega/scroll-velocity.tsx new file mode 100644 index 0000000..0a8c147 --- /dev/null +++ b/web/components/reactomega/scroll-velocity.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +export interface ScrollVelocityProps { + children: React.ReactNode; + /** Extra classes for the clipping row. @default undefined */ + className?: string; + /** Baseline drift in px per second, running even when the page is still. @default 40 */ + baseSpeed?: number; + /** Fraction of the scroll velocity (px/s) added to the drift. @default 0.75 */ + velocityFactor?: number; + /** Degrees of skew per 1000 px/s of scroll velocity. @default 5 */ + skew?: number; + /** Hard cap on the skew, in degrees. @default 12 */ + maxSkew?: number; + /** Direction of the baseline drift. @default "left" */ + direction?: "left" | "right"; +} + +/** Space between repeats, in px. Also the seam gap, so the loop period is width + this. */ +const SEAM = 32; +/** Scroll velocity is clamped here (px/s) so a flick or a scroll-to-top cannot slingshot the row. */ +const MAX_VELOCITY = 2600; + +/** + * ScrollVelocity — a row of repeated content that always drifts at a baseline speed but + * is shoved along by how fast you are scrolling, skewing into the lurch and easing back + * to its baseline when you stop. + * + * Velocity is the change in `scrollY` divided by the real frame delta, then smoothed with + * a time-constant exponential filter (`1 - e^(-dt/tau)`) rather than a fixed per-frame + * lerp, so the feel is identical at 60Hz and 120Hz. It is clamped before use, which is + * what stops a scroll-to-top or a trackpad flick from launching the row across the screen. + * The smoothing is what makes it *ease* back rather than snap: when scrolling stops the + * instantaneous velocity is 0 and the filter decays into it. + * + * The loop is seamless in the marquee way — the track is duplicated and the offset is + * wrapped with a modulo of one repeat's period (measured width plus the seam gap, taken + * from a ResizeObserver so the transform on the row can never contaminate it). Because the + * offset is normalised into [0, period) every frame, scroll velocity may reverse the row + * past its baseline direction without ever exposing an edge. + * + * The animation frame only runs while the row is on screen, and reduced-motion users get a + * single static, wrapped copy — all content readable, nothing clipped or in motion. + */ +export function ScrollVelocity({ + children, + className, + baseSpeed = 40, + velocityFactor = 0.75, + skew = 5, + maxSkew = 12, + direction = "left", +}: ScrollVelocityProps) { + const rootRef = useRef(null); + const rowRef = useRef(null); + const copyRef = useRef(null); + const reduced = usePrefersReducedMotion(); + + useEffect(() => { + if (reduced) return; + const root = rootRef.current; + const row = rowRef.current; + const copy = copyRef.current; + if (!root || !row || !copy) return; + + const dir = direction === "right" ? -1 : 1; + let period = 0; + let x = 0; + let velocity = 0; + let last = 0; + let lastY = window.scrollY; + let raf = 0; + + const step = (now: number) => { + // Clamped so a background tab or a long frame cannot teleport the row. + const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60; + last = now; + + const y = window.scrollY; + const instant = dt > 0 ? (y - lastY) / dt : 0; + lastY = y; + + const k = 1 - Math.exp(-dt / 0.09); + velocity += (instant - velocity) * k; + if (velocity > MAX_VELOCITY) velocity = MAX_VELOCITY; + else if (velocity < -MAX_VELOCITY) velocity = -MAX_VELOCITY; + + x += (baseSpeed * dir + velocity * velocityFactor) * dt; + if (period > 0) x = ((x % period) + period) % period; + + let deg = (velocity / 1000) * skew; + if (deg > maxSkew) deg = maxSkew; + else if (deg < -maxSkew) deg = -maxSkew; + + row.style.transform = `translate3d(${(-x).toFixed(2)}px, 0, 0) skewX(${deg.toFixed(2)}deg)`; + raf = requestAnimationFrame(step); + }; + + const start = () => { + if (raf) return; + // Re-prime the clock and the scroll anchor, otherwise resuming produces one + // enormous fake velocity spike from everything that happened while paused. + last = 0; + lastY = window.scrollY; + raf = requestAnimationFrame(step); + }; + const stop = () => { + if (!raf) return; + cancelAnimationFrame(raf); + raf = 0; + }; + + const ro = new ResizeObserver((entries) => { + period = entries[0].contentRect.width + SEAM; + }); + ro.observe(copy); + + const io = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) start(); + else stop(); + }, + { rootMargin: "160px 0px" }, + ); + io.observe(root); + + return () => { + stop(); + ro.disconnect(); + io.disconnect(); + row.style.transform = ""; + }; + }, [reduced, baseSpeed, velocityFactor, skew, maxSkew, direction]); + + if (reduced) { + return ( +
+
+ {children} +
+
+ ); + } + + return ( +
+
+ {[0, 1].map((k) => ( +
+ {children} +
+ ))} +
+
+ ); +} diff --git a/web/components/reactomega/thin-film.tsx b/web/components/reactomega/thin-film.tsx new file mode 100644 index 0000000..83ad164 --- /dev/null +++ b/web/components/reactomega/thin-film.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface ThinFilmProps { + className?: string; + /** Mean film thickness in nanometres — picks which interference order you see. @default 380 */ + thickness?: number; + /** Drift speed of the film. @default 1 */ + speed?: number; + /** 0 collapses the fringes to grey, 1 is the raw interference colour. @default 0.68 */ + saturation?: number; + /** Zoom of the thickness field — higher gives tighter fringes. @default 1.9 */ + scale?: number; + /** Colour the whole film is biased toward. @default "#7b6cff" */ + tint?: string; +} + +/** + * ThinFilm — a soap-film / oil-slick sheen whose colour is computed from actual + * thin-film interference rather than a rotated hue wheel. + * + * A warped FBM (crossed with two slow sine trains so the fringes stay crisp) + * gives a thickness field in nanometres. The surface normal comes from finite + * differences of that field, Snell's law bends the view ray to the internal + * angle, and the reflected intensity at three representative wavelengths — + * 610nm, 550nm, 465nm — is the two-beam result I(λ) = sin²(δ/2) with + * δ = 4π·n·d·cosθ/λ + π, the π accounting for the hard-surface phase flip. + * Because red, green and blue go in and out of phase at different rates, the + * band order you get is the real one: magenta into gold into cyan, and the + * colour genuinely shifts with viewing angle. The pointer squeezes the film + * thinner, which walks every fringe under the cursor. + */ +export function ThinFilm({ + className, + thickness = 380, + speed = 1, + saturation = 0.68, + scale = 1.9, + tint = "#7b6cff", +}: ThinFilmProps) { + const uniforms = useMemo( + () => ({ + uTint: hexToRgb(tint), + uThickness: thickness, + uSat: saturation, + uScale: scale, + }), + [tint, thickness, saturation, scale], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float a = 0.5; + float v = 0.0; + for (int i = 0; i < 4; i++) { + v += a * vnoise(p); + p = mat2(1.6, 1.2, -1.2, 1.6) * p; + a *= 0.5; + } + return v; +} + +// Normalised film thickness, 0..1. Warped FBM gives the draining-soap blotches; +// the sine train reinstates the crisp fringe edges noise alone smears away. +float filmField(vec2 p, float t) { + vec2 w = vec2(fbm(p + vec2(0.0, t * 0.11)), fbm(p * 1.13 + vec2(4.7, -t * 0.09))); + float n = fbm(p + 2.3 * w); + float ripple = 0.5 + 0.5 * sin(p.x * 2.7 + p.y * 1.9 + t * 0.8 + n * 5.0); + return mix(n, ripple, 0.34); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + + float t = uTime * 0.5; + vec2 p = uv * uScale; + + // The pointer presses the film thinner. Fringe order is a function of d, so + // thinning here does not brighten the film — it walks the bands sideways. + vec2 rel = uv - pc; + float press = uPointer.z * exp(-dot(rel, rel) * 9.0); + + float e = 0.010; + float h = filmField(p, t); + vec3 n = normalize(vec3((h - filmField(p + vec2(e, 0.0), t)) * 2.2, + (h - filmField(p + vec2(0.0, e), t)) * 2.2, 0.22)); + + // Snell: the optical path inside the film uses the refracted angle, not the + // incident one, which is why the fringes crowd toward grazing incidence. + float ior = 1.42; + float cosI = clamp(n.z, 0.05, 1.0); + float sinT = sqrt(max(0.0, 1.0 - cosI * cosI)) / ior; + float cosT = sqrt(max(0.0, 1.0 - sinT * sinT)); + + // A narrower thickness swing means fewer fringes across the frame, so the + // bands stay broad and pastel instead of stacking into a rainbow moiré. + float d = uThickness * (0.62 + 0.55 * h) * (1.0 - 0.45 * press); + + // Two-beam interference at three representative wavelengths. + const vec3 LAMBDA = vec3(610.0, 550.0, 465.0); + vec3 delta = (12.566370614 * ior * d * cosT) / LAMBDA + 3.14159265; + vec3 film = 0.5 - 0.5 * cos(delta); + + // Knock the green channel back: unweighted, the middle wavelength peaks alone + // and the frame reads as a heat map rather than an oil slick. + film = pow(film, vec3(0.94, 1.18, 1.0)); + float lum = dot(film, vec3(0.2126, 0.7152, 0.0722)); + film = clamp(mix(vec3(lum), film, uSat), 0.0, 1.0); + film *= mix(vec3(1.0), uTint * 1.9 + 0.12, 0.60); + + // Where the film runs thin it reflects almost nothing. That black is what + // stops the whole frame reading as one flat rainbow smear. + float density = smoothstep(0.15, 0.72, h) * (0.58 + 0.42 * pow(1.0 - cosI, 2.0)); + vec3 col = uTint * 0.026 + film * density * 0.88; + + vec3 L = normalize(vec3(0.35, 0.68, 0.64)); + col += pow(max(dot(reflect(-L, n), vec3(0.0, 0.0, 1.0)), 0.0), 48.0) * 0.50; + col += uTint * 0.14 * press; + + col *= 1.0 - 0.42 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/reactomega/volumetric-rays.tsx b/web/components/reactomega/volumetric-rays.tsx new file mode 100644 index 0000000..7508322 --- /dev/null +++ b/web/components/reactomega/volumetric-rays.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { useShader, hexToRgb } from "@/hooks/use-shader"; + +export interface VolumetricRaysProps { + className?: string; + /** Colour of the light. @default "#b9c6ff" */ + color?: string; + /** Samples along each ray — quality, not brightness. @default 34 */ + rays?: number; + /** Falloff per unit distance along the ray, 0..1. @default 0.94 */ + decay?: number; + /** Overall gain on the scattering integral. @default 0.42 */ + exposure?: number; + /** Drift speed of the occluding haze. @default 1 */ + speed?: number; +} + +/** + * VolumetricRays — light shafts breaking through drifting haze, with the source + * pinned to the pointer. + * + * This is the radial light-scattering integral, the same one used as a + * post-process in real-time renderers. From each pixel the shader steps back + * toward the light in a straight line; at every step it evaluates the light + * that would arrive there — a bright falloff around the source, multiplied by + * how much of it a two-octave drifting noise field lets past — and adds it in, + * attenuated by a factor that compounds each step. Because every pixel marches + * along its own line to the same origin, near-source brightness gets smeared + * radially outward and the silhouette of the haze becomes the shaft. Decay is + * raised to a per-step power derived from the sample count, so raising `rays` + * refines the integral instead of dimming the frame. + */ +export function VolumetricRays({ + className, + color = "#b9c6ff", + rays = 34, + decay = 0.94, + exposure = 0.42, + speed = 1, +}: VolumetricRaysProps) { + const uniforms = useMemo( + () => ({ + uColor: hexToRgb(color), + uRays: rays, + uDecay: decay, + uExposure: exposure, + }), + [color, rays, decay, exposure], + ); + + const { ref, supported } = useShader({ + speed, + uniforms, + fragment: FRAG, + }); + + if (!supported) { + return ( +
+ ); + } + + return ; +} + +const FRAG = /* glsl */ ` +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +// The haze the light has to get through. Deliberately only two octaves — the +// march itself integrates them into structure, so paying for more is wasted. +float haze(vec2 p, float t) { + float n = vnoise(p * 3.4 + vec2(t * 0.22, -t * 0.13)); + n += 0.5 * vnoise(p * 7.7 - vec2(t * 0.17, t * 0.28)); + // Hard-ish edges on purpose: a soft occluder integrates into fog, and what + // makes a shaft legible is the sharp boundary between lit and blocked. + return smoothstep(0.40, 0.64, n / 1.5); +} + +void main() { + float m = min(uResolution.x, uResolution.y); + vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / m; + vec2 pc = (uPointer.xy - 0.5 * uResolution) / m; + float t = uTime; + + // Light sits high and centred until the pointer takes it over. + vec2 lp = mix(vec2(0.0, 0.30), pc, uPointer.z); + + float samples = clamp(uRays, 8.0, 64.0); + vec2 delta = (uv - lp) / samples * 0.94; + vec2 pos = uv; + float illum = 1.0; + float sum = 0.0; + + // Decay is per unit distance, not per step, so sample count is pure quality. + float dec = pow(clamp(uDecay, 0.60, 0.999), 32.0 / samples); + + for (int i = 0; i < 64; i++) { + if (float(i) >= samples) break; + pos -= delta; + float src = exp(-length(pos - lp) * 2.6); + sum += src * (1.0 - haze(pos * 3.1, t)) * illum; + illum *= dec; + } + sum = sum / samples * uExposure * 5.6; // mean along the ray, not the total + + float dl = length(uv - lp); + vec3 col = uColor * 0.012; + col += uColor * sum * exp(-dl * 0.75); + col += uColor * 0.09 * exp(-dl * 2.4); // soft bloom + col += uColor * 0.45 * exp(-dl * 8.0); // the source itself + + col = 1.0 - exp(-col * 1.15); // tone map — the source glows without clipping + col *= 1.0 - 0.30 * dot(uv, uv); + col += (hash(gl_FragCoord.xy + t) - 0.5) * 0.012; + + fragColor = vec4(max(col, 0.0), 1.0); +} +`; diff --git a/web/components/site/demos.tsx b/web/components/site/demos.tsx index 8600b0c..bcc191a 100644 --- a/web/components/site/demos.tsx +++ b/web/components/site/demos.tsx @@ -32,8 +32,28 @@ import { MagneticField } from "@/components/reactomega/magnetic-field"; import { SoftBody } from "@/components/reactomega/soft-body"; import { PluckedString } from "@/components/reactomega/plucked-string"; import { FallingSand } from "@/components/reactomega/falling-sand"; +import { LiquidMetal } from "@/components/reactomega/liquid-metal"; +import { ThinFilm } from "@/components/reactomega/thin-film"; +import { Caustics } from "@/components/reactomega/caustics"; +import { HalftoneGradient } from "@/components/reactomega/halftone-gradient"; +import { VolumetricRays } from "@/components/reactomega/volumetric-rays"; +import { CurlFlow } from "@/components/reactomega/curl-flow"; +import { InertiaCursor } from "@/components/reactomega/inertia-cursor"; +import { ElasticCursor } from "@/components/reactomega/elastic-cursor"; +import { ImageTrail } from "@/components/reactomega/image-trail"; +import { PixelTrail } from "@/components/reactomega/pixel-trail"; +import { Crosshair } from "@/components/reactomega/crosshair"; +import { ScrollStack } from "@/components/reactomega/scroll-stack"; +import { ScrollVelocity } from "@/components/reactomega/scroll-velocity"; +import { ParallaxLayers, ParallaxLayer } from "@/components/reactomega/parallax-layers"; +import { ScrollScene } from "@/components/reactomega/scroll-scene"; +import { CardSwap } from "@/components/reactomega/card-swap"; +import { GooeyNav } from "@/components/reactomega/gooey-nav"; +import { BentoGrid, BentoTile } from "@/components/reactomega/bento-grid"; +import { ElasticSlider } from "@/components/reactomega/elastic-slider"; +import { CircularGallery } from "@/components/reactomega/circular-gallery"; -export type Category = "text" | "interaction" | "components" | "physics"; +export type Category = "text" | "interaction" | "cursor" | "scroll" | "shader" | "physics" | "components"; export interface Demo { name: string; @@ -41,6 +61,7 @@ export interface Demo { category: Category; node: ReactNode; fill?: boolean; // demo fills the frame (canvas/physics) + tall?: boolean; // needs real page height to demo (scroll-driven) — rendered full-width } const center = "flex h-full w-full items-center justify-center p-4 text-center"; @@ -59,6 +80,43 @@ function Loop({ ms = 3800, children }: { ms?: number; children: ReactNode }) { ); } +/** + * Mounts its children only while the pointer is over the card. The cursor + * components are deliberately global (fixed, full-viewport) — three of them + * mounted at once in a gallery would fight over the same pointer, so each one + * takes over only while you are actually looking at it. + */ +function HoverMount({ label, children }: { label: string; children: ReactNode }) { + const [on, setOn] = useState(false); + return ( +
setOn(true)} + onPointerLeave={() => setOn(false)} + > +
{label}
+ {on ? children : null} +
+ ); +} + +/** Inline SVG placeholder art — keeps the playground free of external image requests. */ +const shot = (hue: number, label: string) => + "data:image/svg+xml," + + encodeURIComponent( + '' + + '' + + '' + + '' + + "" + + '' + + '' + label + "", + ); + +const SHOTS = [258, 200, 158, 320, 30, 286].map((h, i) => shot(h, String(i + 1))); +const GALLERY = SHOTS.map((src, i) => ({ src, alt: "", label: "Plate " + (i + 1) })); + export const DEMOS: Demo[] = [ // ---- text ---- { name: "split-text", title: "Split Text", category: "text", node:
}, @@ -98,11 +156,42 @@ export const DEMOS: Demo[] = [ { name: "soft-body", title: "Soft Body", category: "physics", fill: true, node: }, { name: "plucked-string", title: "Plucked String", category: "physics", fill: true, node: }, { name: "falling-sand", title: "Falling Sand", category: "physics", fill: true, node: }, + + // ---- shaders (raw WebGL2) ---- + { name: "liquid-metal", title: "Liquid Metal", category: "shader", fill: true, node: }, + { name: "thin-film", title: "Thin Film", category: "shader", fill: true, node: }, + { name: "caustics", title: "Caustics", category: "shader", fill: true, node: }, + { name: "halftone-gradient", title: "Halftone Gradient", category: "shader", fill: true, node: }, + { name: "volumetric-rays", title: "Volumetric Rays", category: "shader", fill: true, node: }, + { name: "curl-flow", title: "Curl Flow", category: "shader", fill: true, node: }, + + // ---- cursor & pointer ---- + { name: "inertia-cursor", title: "Inertia Cursor", category: "cursor", fill: true, node: }, + { name: "elastic-cursor", title: "Elastic Cursor", category: "cursor", fill: true, node: }, + { name: "image-trail", title: "Image Trail", category: "cursor", fill: true, node:
Move across the card
}, + { name: "pixel-trail", title: "Pixel Trail", category: "cursor", fill: true, node: }, + { name: "crosshair", title: "Crosshair", category: "cursor", fill: true, node:
{["ASK", "BID", "MID", "LAST"].map((t) => ({t}))}
}, + + // ---- scroll-driven ---- + { name: "scroll-velocity", title: "Scroll Velocity", category: "scroll", fill: true, node:
{["SCROLL", "FASTER", "IT LURCHES", "THEN SETTLES"].map((t) => ({t}))}
}, + { name: "parallax-layers", title: "Parallax Layers", category: "scroll", fill: true, node:
BACKMIDDLEFRONT
}, + { name: "scroll-stack", title: "Scroll Stack", category: "scroll", tall: true, node: {[["Regulation", "Read it before opening Figma."], ["Risk", "The default must be the safe one."], ["Evidence", "Every number carries its source."], ["Scale", "Built once, applied everywhere."]].map(([h, p], i) => (
0{i + 1}

{h}

{p}

))}
}, + { name: "scroll-scene", title: "Scroll Scene", category: "scroll", tall: true, node: {(p) => (
PROGRESS {(p * 100).toFixed(0)}%
Scrubbed by scroll
)} }, + + // ---- components (new) ---- + { name: "card-swap", title: "Card Swap", category: "components", fill: true, node:
{[["Ω", "ReactOmega"], ["◆", "Own the code"], ["✦", "AI-native"]].map(([g, t]) => (
{g}{t}
))}
}, + { name: "gooey-nav", title: "Gooey Nav", category: "components", node:
}, + { name: "bento-grid", title: "Bento Grid", category: "components", fill: true, node: Spotlight follows youTiltGlowOne listener for the whole grid }, + { name: "elastic-slider", title: "Elastic Slider", category: "components", node:
min} rightIcon={max} />
}, + { name: "circular-gallery", title: "Circular Gallery", category: "components", fill: true, node: }, ]; export const CATEGORIES: { id: Category; label: string }[] = [ { id: "text", label: "Text" }, { id: "interaction", label: "Interaction" }, - { id: "components", label: "Components" }, + { id: "cursor", label: "Cursor & Pointer" }, + { id: "scroll", label: "Scroll-Driven" }, + { id: "shader", label: "Shaders & Light" }, { id: "physics", label: "Physics & Art" }, + { id: "components", label: "Components" }, ]; diff --git a/web/hooks/use-shader.ts b/web/hooks/use-shader.ts new file mode 100644 index 0000000..bde1597 --- /dev/null +++ b/web/hooks/use-shader.ts @@ -0,0 +1,323 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"; + +/** A uniform value: float, vec2, vec3 or vec4. */ +export type UniformValue = number | readonly [number, number] | readonly [number, number, number] | readonly [number, number, number, number]; + +export interface UseShaderOptions { + /** + * GLSL ES 3.00 fragment shader body. The hook prepends `#version 300 es`, + * a highp precision qualifier, the built-in uniforms and `out vec4 fragColor` + * — so your source starts at your own helpers / `void main()`. + * + * Built-ins available to every shader: + * uniform vec2 uResolution; // drawing-buffer size in device pixels + * uniform float uTime; // seconds since mount, scaled by `speed` + * uniform vec3 uPointer; // xy = pointer in device px (y up), z = 0..1 eased hover + */ + fragment: string; + /** + * Custom uniforms. Declarations are generated from the arity of each value, + * so `{ uTint: [0.48, 0.36, 1] }` emits `uniform vec3 uTint;` automatically. + * Values are re-read every frame — changing a prop never recompiles. + */ + uniforms?: Record; + /** Time multiplier. @default 1 */ + speed?: number; + /** Device-pixel-ratio ceiling. Caps GPU cost on retina displays. @default 2 */ + dpr?: number; + /** Track the pointer and feed `uPointer`. @default true */ + pointer?: boolean; + /** Freeze the loop without unmounting. @default false */ + paused?: boolean; + /** + * Frozen timestamp rendered as a single still frame for reduced-motion + * users — pick one where the shader looks composed. @default 12 + */ + staticTime?: number; +} + +const VERT = `#version 300 es +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +}`; + +const TYPES = ["float", "vec2", "vec3", "vec4"]; +const arity = (v: UniformValue) => (typeof v === "number" ? 1 : v.length); + +function compile(gl: WebGL2RenderingContext, type: number, src: string) { + const sh = gl.createShader(type); + if (!sh) return null; + gl.shaderSource(sh, src); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + // A lost context fails every compile with a null info log. That is not a + // shader error and the restore handler will rebuild — don't cry wolf. + if (process.env.NODE_ENV !== "production" && !gl.isContextLost()) { + console.error("[ReactOmega] shader compile failed:\n" + gl.getShaderInfoLog(sh)); + } + gl.deleteShader(sh); + return null; + } + return sh; +} + +/** + * useShader — a dependency-free raw WebGL2 runtime for full-screen fragment shaders. + * + * Draws a single full-screen triangle with no vertex buffers (positions come from + * `gl_VertexID`), so there is nothing to allocate and nothing to leak. It owns the + * whole lifecycle: DPR-clamped sizing via ResizeObserver, an IntersectionObserver + * that suspends the render loop while the canvas is off-screen, pausing on tab + * blur, context-loss recovery, eased pointer input, and a single still frame for + * reduced-motion users. Time does not jump after a pause — it accumulates only + * while visible. + * + * Returns the canvas ref plus `supported`, which is `false` when WebGL2 is + * unavailable so the component can fall back to CSS instead of a blank box. + */ +export function useShader({ + fragment, + uniforms, + speed = 1, + dpr = 2, + pointer = true, + paused = false, + staticTime = 12, +}: UseShaderOptions) { + const ref = useRef(null); + const [supported, setSupported] = useState(true); + const reduced = usePrefersReducedMotion(); + + // Latest values, re-read each frame so prop changes never trigger a recompile. + const live = useRef({ uniforms, speed, paused }); + live.current = { uniforms, speed, paused }; + + // Uniform declarations are part of the program source, so they *are* a dep. + const signature = uniforms ? Object.entries(uniforms).map(([k, v]) => `${k}:${arity(v)}`).join(",") : ""; + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + + const gl = canvas.getContext("webgl2", { + alpha: true, + antialias: false, + premultipliedAlpha: true, + powerPreference: "low-power", + failIfMajorPerformanceCaveat: false, + }); + if (!gl) { + setSupported(false); + return; + } + setSupported(true); + + const decls = Object.entries(live.current.uniforms ?? {}) + .map(([k, v]) => `uniform ${TYPES[arity(v) - 1]} ${k};`) + .join("\n"); + + const frag = `#version 300 es +precision highp float; +uniform vec2 uResolution; +uniform float uTime; +uniform vec3 uPointer; +${decls} +out vec4 fragColor; +${fragment}`; + + let program: WebGLProgram | null = null; + let locs: Record = {}; + + const build = () => { + if (gl.isContextLost()) return false; // the restore handler will retry + const vs = compile(gl, gl.VERTEX_SHADER, VERT); + const fs = compile(gl, gl.FRAGMENT_SHADER, frag); + if (!vs || !fs) return false; + const p = gl.createProgram(); + if (!p) return false; + gl.attachShader(p, vs); + gl.attachShader(p, fs); + gl.linkProgram(p); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { + if (process.env.NODE_ENV !== "production" && !gl.isContextLost()) { + console.error("[ReactOmega] shader link failed:\n" + gl.getProgramInfoLog(p)); + } + gl.deleteProgram(p); + return false; + } + program = p; + locs = {}; + gl.useProgram(p); + return true; + }; + + const onLost = (e: Event) => { + e.preventDefault(); + program = null; + }; + const onRestored = () => { + if (build()) { + w = h = 0; + resizeRef.current?.(); + if (reduced) drawRef.current?.(staticTime); + } else { + setSupported(false); + } + }; + canvas.addEventListener("webglcontextlost", onLost); + canvas.addEventListener("webglcontextrestored", onRestored); + + // Registered above so a context that is already down at mount can still come + // back. Only a genuine compile/link failure marks the component unsupported. + if (!build() && !gl.isContextLost()) { + setSupported(false); + canvas.removeEventListener("webglcontextlost", onLost); + canvas.removeEventListener("webglcontextrestored", onRestored); + return; + } + + const loc = (name: string) => (name in locs ? locs[name] : (locs[name] = gl.getUniformLocation(program!, name))); + + // onRestored is registered before resize/draw are defined, so it reaches + // them indirectly. + const resizeRef: { current: (() => void) | null } = { current: null }; + const drawRef: { current: ((t: number) => void) | null } = { current: null }; + + // ---- sizing ------------------------------------------------------------- + let w = 0; + let h = 0; + const ratio = () => Math.min(dpr, typeof window === "undefined" ? 1 : window.devicePixelRatio || 1); + const resize = () => { + const r = canvas.getBoundingClientRect(); + const px = ratio(); + const nw = Math.max(1, Math.round(r.width * px)); + const nh = Math.max(1, Math.round(r.height * px)); + if (nw === w && nh === h) return; + w = canvas.width = nw; + h = canvas.height = nh; + gl.viewport(0, 0, w, h); + }; + resizeRef.current = resize; + resize(); + const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null; + ro?.observe(canvas); + + // ---- pointer ------------------------------------------------------------ + const ptr = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, hover: 0, thover: 0 }; + const onMove = (e: PointerEvent) => { + const r = canvas.getBoundingClientRect(); + if (!r.width || !r.height) return; + ptr.tx = (e.clientX - r.left) / r.width; + ptr.ty = 1 - (e.clientY - r.top) / r.height; // y-up, matches gl_FragCoord + ptr.thover = 1; + }; + const onLeave = () => { + ptr.thover = 0; + ptr.tx = 0.5; + ptr.ty = 0.5; + }; + if (pointer && !reduced) { + canvas.addEventListener("pointermove", onMove, { passive: true }); + canvas.addEventListener("pointerleave", onLeave, { passive: true }); + } + + // ---- draw --------------------------------------------------------------- + const draw = (t: number) => { + if (!program) return; + gl.useProgram(program); + resize(); + gl.uniform2f(loc("uResolution")!, w, h); + gl.uniform1f(loc("uTime")!, t); + gl.uniform3f(loc("uPointer")!, ptr.x * w, ptr.y * h, ptr.hover); + for (const [k, v] of Object.entries(live.current.uniforms ?? {})) { + const l = loc(k); + if (!l) continue; + if (typeof v === "number") gl.uniform1f(l, v); + else if (v.length === 2) gl.uniform2f(l, v[0], v[1]); + else if (v.length === 3) gl.uniform3f(l, v[0], v[1], v[2]); + else gl.uniform4f(l, v[0], v[1], v[2], v[3]); + } + gl.drawArrays(gl.TRIANGLES, 0, 3); + }; + + drawRef.current = draw; + + // ---- loop --------------------------------------------------------------- + let raf = 0; + let clock = 0; // accumulates only while actually rendering + let last = 0; + let visible = true; + + if (reduced) { + ptr.hover = 0; + draw(staticTime); + } else { + const frame = (now: number) => { + raf = requestAnimationFrame(frame); + if (!visible || document.hidden || live.current.paused) { + last = now; + return; + } + // Clamp dt so returning to a backgrounded tab never fast-forwards. + const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016); + last = now; + clock += dt * live.current.speed; + ptr.x += (ptr.tx - ptr.x) * 0.12; + ptr.y += (ptr.ty - ptr.y) * 0.12; + ptr.hover += (ptr.thover - ptr.hover) * 0.08; + draw(clock); + }; + raf = requestAnimationFrame(frame); + } + + // Suspend entirely while scrolled out of view — many shaders on one page + // stay cheap because only the visible ones burn GPU. + const io = + typeof IntersectionObserver !== "undefined" + ? new IntersectionObserver( + (entries) => { + visible = entries[0]?.isIntersecting ?? true; + if (visible) last = 0; + }, + { rootMargin: "120px" }, + ) + : null; + io?.observe(canvas); + + return () => { + cancelAnimationFrame(raf); + ro?.disconnect(); + io?.disconnect(); + canvas.removeEventListener("pointermove", onMove); + canvas.removeEventListener("pointerleave", onLeave); + canvas.removeEventListener("webglcontextlost", onLost); + canvas.removeEventListener("webglcontextrestored", onRestored); + if (program) gl.deleteProgram(program); + // Deliberately NOT calling WEBGL_lose_context here. A canvas returns the + // same context object on every getContext call, so losing it would poison + // every later mount on that canvas — React StrictMode re-runs effects in + // dev, and any prop in the dependency list re-runs them in production. + // The context is released when the canvas itself is collected. + }; + }, [fragment, signature, speed, dpr, pointer, reduced, staticTime]); + + return { ref, supported }; +} + +/** + * Parse `#rgb` / `#rrggbb` into a linear-ish 0..1 triple for shader uniforms. + * Returns mid-grey for anything unparseable so a typo never blanks the canvas. + */ +export function hexToRgb(hex: string): [number, number, number] { + let h = hex.trim().replace(/^#/, ""); + if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + if (!/^[0-9a-fA-F]{6}$/.test(h)) return [0.5, 0.5, 0.5]; + const n = parseInt(h, 16); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; +}