diff --git a/.cursor/skills/23rd/apis.md b/.cursor/skills/23rd/apis.md index 5c8a0e8..ff35c1c 100644 --- a/.cursor/skills/23rd/apis.md +++ b/.cursor/skills/23rd/apis.md @@ -113,12 +113,13 @@ type RingTowerProps = { speed?: number // DEFAULT_SPEED 1, 0-4 crawl speed draggable?: boolean // true parallax?: number // DEFAULT_PARALLAX 24, degrees, 0-90 - backface?: number // DEFAULT_BACKFACE 0.86, inside darkness, 0-1 + backface?: number // DEFAULT_BACKFACE 0.86, inside fade, 0-1 + theme?: "light" | "dark" | "auto" // "auto" className?: string } ``` -No `theme`; the canvas is transparent and the images carry the color. Instance: `{ setOptions, destroy }`. +The inside of the bands fades toward black in dark mode and white in light mode. The canvas is transparent; the images carry the color. Instance: `{ setOptions, destroy }`. ## ascii-fluid diff --git a/.cursor/skills/23rd/components.md b/.cursor/skills/23rd/components.md index b7e823e..a77ca94 100644 --- a/.cursor/skills/23rd/components.md +++ b/.cursor/skills/23rd/components.md @@ -99,7 +99,7 @@ Best fit: a showcase hero that puts screenshots or cards on display. Not this: `shader-metal` (ribbons of material, no images). -Deps: `three`. Key props: `images`, `rings` (12), `speed` (1), `draggable` (true), `parallax` (24), `backface` (0.86). +Deps: `three`. Key props: `images`, `rings` (12), `speed` (1), `draggable` (true), `parallax` (24), `backface` (0.86), `theme`. ```tsx "use client" @@ -107,7 +107,7 @@ import { RingTower } from "@/components/ui/ring-tower" export function Showcase() { return ( -
+
) diff --git a/.cursor/skills/23rd/conventions.md b/.cursor/skills/23rd/conventions.md index 137c76b..d746f37 100644 --- a/.cursor/skills/23rd/conventions.md +++ b/.cursor/skills/23rd/conventions.md @@ -90,6 +90,7 @@ Vanilla: - No React, no Svelte, no `@/` imports. - Export an options type, an instance type `{ setOptions, destroy }`, and `create(canvas | root, options)`. +- Every component works in light and dark mode. Expose `theme?: "light" | "dark" | "auto"` (default `"auto"`) and give anything theme-sensitive (ink, fades, backfaces, glows) a light and a dark value. Watch `html` attributes and `prefers-color-scheme` so `auto` follows the page live. - Theme helper: `html.dark` / `html.light`, then `data-theme`, then `prefers-color-scheme`. Copy `isDarkTheme` from `logo-burst-vanilla.ts` rather than inventing a new contract. - Honor `prefers-reduced-motion: reduce`. - Custom `colors` or `color`, when set, win over the theme palette. @@ -97,6 +98,7 @@ Vanilla: Demos: - `*-demo.tsx` is for the docs site only. It is not a registry file. +- Demo stages and docs examples use theme tokens (`bg-background`, `from-background`, `text-foreground`), never a hard-coded black or white stage. Check the demo in both themes before shipping. - Use `ComponentPreview`, `ComponentControls`, and `usePreviewProps` the way `shader-gradient-demo.tsx` does. - `gooey-color-picker` has no demo file; the MDX renders `` inside `ComponentPreview`. Either pattern is acceptable. Do not publish the demo. @@ -171,6 +173,7 @@ pnpm typecheck ## Do not - Publish a React-only item. Tests require a Svelte port. +- Ship a component that only looks right in one theme. - Import `./-vanilla` from a file that is not the wrapper the build inlines. - Put site chrome (`components/docs-shell.tsx`, `components/ui/button.tsx`) into the registry. `components/ui/*` in this repo is the docs site’s shadcn kit, not the published set. - Add a `variant` prop unless the design really has discrete materials. Only `live-orb` has one. diff --git a/content/docs/components/ring-tower.mdx b/content/docs/components/ring-tower.mdx index f039ae3..fdfc069 100644 --- a/content/docs/components/ring-tower.mdx +++ b/content/docs/components/ring-tower.mdx @@ -21,7 +21,7 @@ Installs `three` alongside the component. ## Usage -Pin it inside a relatively positioned section with a height. The canvas is transparent, so give the section a background. Each band leans a quarter turn from the last, so the stack zig-zags past the edges of the frame. +Pin it inside a relatively positioned section with a height. The canvas is transparent, so give the section a background. The inside of the bands fades toward black in dark mode and white in light mode, following the page unless you set `theme`. Each band leans a quarter turn from the last, so the stack zig-zags past the edges of the frame. @@ -34,9 +34,9 @@ const bands = ["/bands/0.jpg", "/bands/1.jpg", "/bands/2.jpg"] export function Hero() { return ( -
+
-
+
) } @@ -50,9 +50,9 @@ export function Hero() { const bands = ["/bands/0.jpg", "/bands/1.jpg", "/bands/2.jpg"] -
+
-
+
``` @@ -84,6 +84,7 @@ Every band in step, like the original: | `speed` | `number` | `1`, 0–4 crawl speed | | `draggable` | `boolean` | `true` | | `parallax` | `number` | `24`, degrees, 0–90 | -| `backface` | `number` | `0.86`, inside darkness, 0–1 | +| `backface` | `number` | `0.86`, inside fade, 0–1 | +| `theme` | `"light" \| "dark" \| "auto"` | `auto` | | `className` | `string` | React only | | `class` | `string` | Svelte only | diff --git a/public/r/ring-tower-svelte.json b/public/r/ring-tower-svelte.json index f0eca88..75d72f9 100644 --- a/public/r/ring-tower-svelte.json +++ b/public/r/ring-tower-svelte.json @@ -12,7 +12,7 @@ "files": [ { "path": "registry/ring-tower/ring-tower.svelte", - "content": "\n\n\n\n\n", + "content": "\n\n\n\n\n", "type": "registry:file", "target": "src/lib/components/ui/ring-tower.svelte" } diff --git a/public/r/ring-tower.json b/public/r/ring-tower.json index 27657dd..fd8a444 100644 --- a/public/r/ring-tower.json +++ b/public/r/ring-tower.json @@ -12,7 +12,7 @@ "files": [ { "path": "registry/ring-tower/ring-tower.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n CanvasTexture,\n ClampToEdgeWrapping,\n CylinderGeometry,\n DoubleSide,\n Group,\n LinearFilter,\n Mesh,\n MeshBasicMaterial,\n PerspectiveCamera,\n Raycaster,\n RepeatWrapping,\n Scene,\n SRGBColorSpace,\n Vector2,\n WebGLRenderer,\n type Texture,\n} from \"three\"\n\nexport type RingTowerOptions = {\n /**\n * Image URLs drawn side by side around every band. Cross-origin images\n * need CORS headers. Empty draws a plain grid band.\n */\n images?: string[]\n /** Number of stacked bands. Default `12`, 1 to 24. */\n rings?: number\n /** How fast the images crawl around the bands. Default `1`, 0 to 4. */\n speed?: number\n /** Drag a band to spin the tower, with inertia. Default `true`. */\n draggable?: boolean\n /**\n * How far, in degrees, the pointer turns each band. Neighbors turn the\n * opposite way. Default `24`, 0 to 90. `0` keeps every band in step.\n */\n parallax?: number\n /** How dark the inside of the bands is, 0 to 1. Default `0.86`. */\n backface?: number\n}\n\nexport type RingTowerInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_RINGS = 12\nexport const DEFAULT_SPEED = 1\nexport const DEFAULT_PARALLAX = 24\nexport const DEFAULT_BACKFACE = 0.86\n\nconst RADIUS = 4\nconst BAND_HEIGHT = 2\nconst SPACING = 3.5\nconst TILT = 0.25\nconst SEGMENTS = 100\nconst STRIP_HEIGHT = 512\nconst CRAWL = 0.012\nconst START_ANGLE = 0.5\n/** Lean cycle per ring; signs alternate so neighbors counter-turn. */\nconst PARALLAX_FACTORS = [1, -0.75, 1.25, -0.9]\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\nfunction loadImage(url: string) {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.decoding = \"async\"\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(`RingTower: failed to load ${url}`))\n img.src = url\n })\n}\n\ntype Strip = { canvas: HTMLCanvasElement; aspect: number }\n\n/** Draws the images side by side at one height on a white strip. */\nfunction stitch(images: HTMLImageElement[], maxSize: number): Strip | null {\n const sizes = images.map((img) => {\n const aspect = img.naturalWidth / Math.max(1, img.naturalHeight)\n return { img, width: STRIP_HEIGHT * aspect }\n })\n const width = sizes.reduce((sum, s) => sum + s.width, 0)\n if (width <= 0) return null\n\n const canvas = document.createElement(\"canvas\")\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n const scale = Math.min(\n Math.min(window.devicePixelRatio || 1, 2),\n maxSize / width,\n maxSize / STRIP_HEIGHT\n )\n canvas.width = Math.max(1, Math.floor(width * scale))\n canvas.height = Math.max(1, Math.floor(STRIP_HEIGHT * scale))\n ctx.scale(scale, scale)\n ctx.fillStyle = \"#ffffff\"\n ctx.fillRect(0, 0, width, STRIP_HEIGHT)\n let x = 0\n for (const s of sizes) {\n ctx.drawImage(s.img, x, 0, s.width, STRIP_HEIGHT)\n x += s.width\n }\n return { canvas, aspect: width / STRIP_HEIGHT }\n}\n\n/** Plain green grid band, used when no image loads. */\nfunction gridStrip(): Strip | null {\n const canvas = document.createElement(\"canvas\")\n canvas.width = 1024\n canvas.height = 512\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n const fill = ctx.createLinearGradient(0, 0, 0, 512)\n fill.addColorStop(0, \"#36C26A\")\n fill.addColorStop(1, \"#22A653\")\n ctx.fillStyle = fill\n ctx.fillRect(0, 0, 1024, 512)\n ctx.strokeStyle = \"rgba(255, 255, 255, 0.16)\"\n ctx.lineWidth = 2\n for (let i = 0; i <= 1024; i += 32) {\n ctx.beginPath()\n ctx.moveTo(i, 0)\n ctx.lineTo(i, 512)\n ctx.stroke()\n }\n for (let i = 0; i <= 512; i += 32) {\n ctx.beginPath()\n ctx.moveTo(0, i)\n ctx.lineTo(1024, i)\n ctx.stroke()\n }\n return { canvas, aspect: 2 }\n}\n\n/**\n * A tower of open, tilted cylinder bands wrapped in a strip of images, after\n * the vuemail.dev hero. Each band leans a quarter turn from the last, so the\n * stack zig-zags. The images crawl around the bands, dragging spins the\n * tower with inertia, and the pointer turns neighboring bands opposite ways.\n * The canvas is transparent; the parent paints the background. Pauses\n * off-screen and in hidden tabs; holds still under `prefers-reduced-motion`.\n */\nexport function createRingTower(\n root: HTMLElement,\n initial: RingTowerOptions = {}\n): RingTowerInstance | null {\n let options: RingTowerOptions = { draggable: true, ...initial }\n\n let renderer: WebGLRenderer\n try {\n renderer = new WebGLRenderer({\n alpha: true,\n antialias: true,\n powerPreference: \"high-performance\",\n })\n } catch {\n return null\n }\n renderer.setPixelRatio(Math.min(Math.max(1, window.devicePixelRatio), 2))\n renderer.outputColorSpace = SRGBColorSpace\n renderer.setClearColor(0x000000, 0)\n const canvas = renderer.domElement\n canvas.style.display = \"block\"\n canvas.style.touchAction = \"pan-y\"\n root.appendChild(canvas)\n\n const scene = new Scene()\n const camera = new PerspectiveCamera(7, 1, 0.01, 1e5)\n camera.position.set(0, 0, 70)\n const tower = new Group()\n tower.rotation.set(-0.2, START_ANGLE, 0.2)\n scene.add(tower)\n\n const geometry = new CylinderGeometry(\n RADIUS,\n RADIUS,\n BAND_HEIGHT,\n SEGMENTS,\n 1,\n true\n )\n const backface = { value: DEFAULT_BACKFACE }\n\n type Band = { mesh: Mesh; material: MeshBasicMaterial; texture: Texture }\n let bands: Band[] = []\n let strip: Strip | null = null\n let base: CanvasTexture | null = null\n let tiles = 1\n\n const makeMaterial = (map: Texture) => {\n const material = new MeshBasicMaterial({\n map,\n side: DoubleSide,\n toneMapped: false,\n })\n material.onBeforeCompile = (shader) => {\n shader.uniforms.uBackface = backface\n shader.fragmentShader = shader.fragmentShader\n .replace(\"void main() {\", \"uniform float uBackface;\\nvoid main() {\")\n .replace(\n \"#include \",\n `#include \n if (!gl_FrontFacing) {\n diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.0), uBackface);\n }`\n )\n }\n material.customProgramCacheKey = () => \"ring-tower\"\n return material\n }\n\n const clearBands = () => {\n for (const band of bands) {\n tower.remove(band.mesh)\n band.material.dispose()\n if (band.texture !== base) band.texture.dispose()\n }\n bands = []\n }\n\n // Whole tiles around the band keep the seam invisible; the strip is then\n // cropped top and bottom to hold its proportions.\n const fit = (texture: Texture, aspect: number) => {\n const around = (2 * Math.PI * RADIUS) / BAND_HEIGHT\n const whole = Math.floor(around / aspect)\n if (whole >= 1) {\n tiles = whole\n texture.repeat.set(whole, Math.min(1, (aspect * whole) / around))\n texture.offset.y = (1 - texture.repeat.y) / 2\n } else {\n tiles = around / aspect\n texture.repeat.set(tiles, 1)\n texture.offset.y = 0\n }\n }\n\n const buildBands = () => {\n clearBands()\n if (!base) return\n const count = Math.round(numberOr(options.rings, DEFAULT_RINGS, 1, 24))\n const middle = Math.ceil(count / 2) - 1\n for (let i = 0; i < count; i++) {\n // Clones share the image source, so the strip uploads once.\n const texture = i === 0 ? base : base.clone()\n const material = makeMaterial(texture)\n const mesh = new Mesh(geometry, material)\n mesh.rotation.set(0, i * Math.PI * 0.5, TILT)\n mesh.position.set(0, (i - middle) * SPACING, 0)\n tower.add(mesh)\n bands.push({ mesh, material, texture })\n }\n }\n\n const setStrip = (next: Strip | null) => {\n clearBands()\n base?.dispose()\n base = null\n strip = next\n if (!strip) return\n const texture = new CanvasTexture(strip.canvas)\n texture.wrapS = RepeatWrapping\n texture.wrapT = ClampToEdgeWrapping\n texture.generateMipmaps = false\n texture.minFilter = LinearFilter\n texture.magFilter = LinearFilter\n texture.colorSpace = SRGBColorSpace\n texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy())\n fit(texture, strip.aspect)\n base = texture\n buildBands()\n wake()\n }\n\n let loadToken = 0\n let imagesKey = \"\"\n const loadImages = () => {\n const urls = (options.images ?? []).filter(\n (url) => typeof url === \"string\" && url.length > 0\n )\n const key = urls.join(\"\\n\")\n if (key === imagesKey && strip) return\n imagesKey = key\n const token = ++loadToken\n if (urls.length === 0) {\n setStrip(gridStrip())\n return\n }\n void Promise.allSettled(urls.map(loadImage)).then((results) => {\n if (token !== loadToken || destroyed) return\n const loaded = results.flatMap((r) =>\n r.status === \"fulfilled\" ? [r.value] : []\n )\n const next = loaded.length\n ? stitch(loaded, renderer.capabilities.maxTextureSize)\n : null\n setStrip(next ?? gridStrip())\n })\n }\n\n const size = { width: 0, height: 0 }\n const resize = () => {\n const { width, height } = root.getBoundingClientRect()\n size.width = width\n size.height = height\n if (width <= 0 || height <= 0) return\n renderer.setSize(width, height)\n camera.aspect = width / height\n camera.updateProjectionMatrix()\n wake()\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n let lost = false\n let last = 0\n let crawl = 0\n let angle = START_ANGLE\n let velocity = 0\n let dragging = false\n let dragX = 0\n let pointerX = 0\n let pointerTarget = 0\n\n const visible = () => onScreen && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (!visible()) return\n const still = mqReduce.matches\n\n if (!still) {\n // In texture units, so wrapping at 1 never shows a jump.\n crawl += dt * CRAWL * tiles * numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n crawl %= 1\n }\n if (!dragging) {\n if (still) velocity = 0\n angle += velocity * dt\n velocity *= Math.pow(0.95, dt * 60)\n if (Math.abs(velocity) < 1e-4) velocity = 0\n }\n tower.rotation.y = angle\n\n const ease = 1 - Math.exp(-dt * 4)\n const target = still ? 0 : pointerTarget\n pointerX += (target - pointerX) * ease\n const reach =\n (numberOr(options.parallax, DEFAULT_PARALLAX, 0, 90) / 360) * tiles\n bands.forEach((band, i) => {\n const factor = PARALLAX_FACTORS[i % PARALLAX_FACTORS.length]!\n band.texture.offset.x = crawl + pointerX * reach * factor\n })\n\n if (size.width > 0 && size.height > 0) renderer.render(scene, camera)\n\n const moving =\n !still ||\n dragging ||\n velocity !== 0 ||\n Math.abs(target - pointerX) > 1e-4\n if (moving) raf = requestAnimationFrame(tick)\n }\n\n const raycaster = new Raycaster()\n const ndc = new Vector2()\n const hits = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect()\n if (rect.width === 0 || rect.height === 0) return false\n ndc.set(\n ((e.clientX - rect.left) / rect.width) * 2 - 1,\n -((e.clientY - rect.top) / rect.height) * 2 + 1\n )\n raycaster.setFromCamera(ndc, camera)\n return raycaster.intersectObjects(tower.children, false).length > 0\n }\n\n const setCursor = (value: string) => {\n if (canvas.style.cursor !== value) canvas.style.cursor = value\n }\n\n const onWindowMove = (e: PointerEvent) => {\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pointerTarget = inside ? clamp(x * 2 - 1, -1, 1) : 0\n wake()\n }\n window.addEventListener(\"pointermove\", onWindowMove, { passive: true })\n\n const onDown = (e: PointerEvent) => {\n if (options.draggable === false || !hits(e)) return\n dragging = true\n dragX = e.clientX\n velocity = 0\n canvas.setPointerCapture(e.pointerId)\n setCursor(\"grabbing\")\n wake()\n }\n const onMove = (e: PointerEvent) => {\n if (dragging) {\n const delta = ((e.clientX - dragX) / Math.max(1, size.width)) * Math.PI * 2\n angle += delta\n velocity = delta * 60\n dragX = e.clientX\n return\n }\n setCursor(options.draggable !== false && hits(e) ? \"grab\" : \"\")\n }\n const onUp = (e: PointerEvent) => {\n if (!dragging) return\n dragging = false\n if (canvas.hasPointerCapture(e.pointerId)) {\n canvas.releasePointerCapture(e.pointerId)\n }\n setCursor(hits(e) ? \"grab\" : \"\")\n wake()\n }\n const onLeave = () => {\n if (!dragging) setCursor(\"\")\n }\n canvas.addEventListener(\"pointerdown\", onDown)\n canvas.addEventListener(\"pointermove\", onMove)\n canvas.addEventListener(\"pointerup\", onUp)\n canvas.addEventListener(\"pointercancel\", onUp)\n canvas.addEventListener(\"pointerleave\", onLeave)\n\n const ro = new ResizeObserver(resize)\n ro.observe(root)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(root)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n // three restores its own state; the loop just has to stop and resume.\n const onLost = () => {\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed) return\n lost = false\n if (base) base.needsUpdate = true\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n const applyBackface = () => {\n backface.value = numberOr(options.backface, DEFAULT_BACKFACE, 0, 1)\n }\n\n applyBackface()\n resize()\n loadImages()\n wake()\n\n return {\n setOptions(next) {\n const prevRings = options.rings\n options = { ...options, ...next }\n applyBackface()\n if (options.draggable === false) {\n dragging = false\n setCursor(\"\")\n }\n if (options.rings !== prevRings) buildBands()\n loadImages()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onWindowMove)\n canvas.removeEventListener(\"pointerdown\", onDown)\n canvas.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"pointerup\", onUp)\n canvas.removeEventListener(\"pointercancel\", onUp)\n canvas.removeEventListener(\"pointerleave\", onLeave)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n clearBands()\n base?.dispose()\n geometry.dispose()\n renderer.dispose()\n renderer.forceContextLoss()\n canvas.remove()\n },\n }\n}\n\nexport type RingTowerProps = RingTowerOptions & {\n className?: string\n}\n\n/**\n * A tower of tilted bands wrapped in your images. The images crawl around\n * the bands, dragging spins the tower, and the pointer turns neighboring\n * bands opposite ways. Transparent; the parent paints the background.\n */\nexport function RingTower({\n className,\n images,\n rings = DEFAULT_RINGS,\n speed = DEFAULT_SPEED,\n draggable = true,\n parallax = DEFAULT_PARALLAX,\n backface = DEFAULT_BACKFACE,\n}: RingTowerProps) {\n const rootRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n if (!root) return\n instanceRef.current = createRingTower(root, {\n images,\n rings,\n speed,\n draggable,\n parallax,\n backface,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n images,\n rings,\n speed,\n draggable,\n parallax,\n backface,\n })\n }, [images, rings, speed, draggable, parallax, backface])\n\n return (\n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n CanvasTexture,\n ClampToEdgeWrapping,\n Color,\n CylinderGeometry,\n DoubleSide,\n Group,\n LinearFilter,\n Mesh,\n MeshBasicMaterial,\n PerspectiveCamera,\n Raycaster,\n RepeatWrapping,\n Scene,\n SRGBColorSpace,\n Vector2,\n WebGLRenderer,\n type Texture,\n} from \"three\"\n\nexport type RingTowerTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type RingTowerOptions = {\n /**\n * Image URLs drawn side by side around every band. Cross-origin images\n * need CORS headers. Empty draws a plain grid band.\n */\n images?: string[]\n /** Number of stacked bands. Default `12`, 1 to 24. */\n rings?: number\n /** How fast the images crawl around the bands. Default `1`, 0 to 4. */\n speed?: number\n /** Drag a band to spin the tower, with inertia. Default `true`. */\n draggable?: boolean\n /**\n * How far, in degrees, the pointer turns each band. Neighbors turn the\n * opposite way. Default `24`, 0 to 90. `0` keeps every band in step.\n */\n parallax?: number\n /**\n * How far the inside of the bands fades toward the page, 0 to 1: black in\n * dark mode, white in light mode. Default `0.86`.\n */\n backface?: number\n /** `\"auto\"` follows the page. Default `\"auto\"`. */\n theme?: RingTowerTheme\n}\n\nexport type RingTowerInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_RINGS = 12\nexport const DEFAULT_SPEED = 1\nexport const DEFAULT_PARALLAX = 24\nexport const DEFAULT_BACKFACE = 0.86\n\nconst RADIUS = 4\nconst BAND_HEIGHT = 2\nconst SPACING = 3.5\nconst TILT = 0.25\nconst SEGMENTS = 100\nconst STRIP_HEIGHT = 512\nconst CRAWL = 0.012\nconst START_ANGLE = 0.5\n/** Lean cycle per ring; signs alternate so neighbors counter-turn. */\nconst PARALLAX_FACTORS = [1, -0.75, 1.25, -0.9]\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: RingTowerTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction loadImage(url: string) {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.crossOrigin = \"anonymous\"\n img.decoding = \"async\"\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(`RingTower: failed to load ${url}`))\n img.src = url\n })\n}\n\ntype Strip = { canvas: HTMLCanvasElement; aspect: number }\n\n/** Draws the images side by side at one height on a white strip. */\nfunction stitch(images: HTMLImageElement[], maxSize: number): Strip | null {\n const sizes = images.map((img) => {\n const aspect = img.naturalWidth / Math.max(1, img.naturalHeight)\n return { img, width: STRIP_HEIGHT * aspect }\n })\n const width = sizes.reduce((sum, s) => sum + s.width, 0)\n if (width <= 0) return null\n\n const canvas = document.createElement(\"canvas\")\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n const scale = Math.min(\n Math.min(window.devicePixelRatio || 1, 2),\n maxSize / width,\n maxSize / STRIP_HEIGHT\n )\n canvas.width = Math.max(1, Math.floor(width * scale))\n canvas.height = Math.max(1, Math.floor(STRIP_HEIGHT * scale))\n ctx.scale(scale, scale)\n ctx.fillStyle = \"#ffffff\"\n ctx.fillRect(0, 0, width, STRIP_HEIGHT)\n let x = 0\n for (const s of sizes) {\n ctx.drawImage(s.img, x, 0, s.width, STRIP_HEIGHT)\n x += s.width\n }\n return { canvas, aspect: width / STRIP_HEIGHT }\n}\n\n/** Plain green grid band, used when no image loads. */\nfunction gridStrip(): Strip | null {\n const canvas = document.createElement(\"canvas\")\n canvas.width = 1024\n canvas.height = 512\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n const fill = ctx.createLinearGradient(0, 0, 0, 512)\n fill.addColorStop(0, \"#36C26A\")\n fill.addColorStop(1, \"#22A653\")\n ctx.fillStyle = fill\n ctx.fillRect(0, 0, 1024, 512)\n ctx.strokeStyle = \"rgba(255, 255, 255, 0.16)\"\n ctx.lineWidth = 2\n for (let i = 0; i <= 1024; i += 32) {\n ctx.beginPath()\n ctx.moveTo(i, 0)\n ctx.lineTo(i, 512)\n ctx.stroke()\n }\n for (let i = 0; i <= 512; i += 32) {\n ctx.beginPath()\n ctx.moveTo(0, i)\n ctx.lineTo(1024, i)\n ctx.stroke()\n }\n return { canvas, aspect: 2 }\n}\n\n/**\n * A tower of open, tilted cylinder bands wrapped in a strip of images, after\n * the vuemail.dev hero. Each band leans a quarter turn from the last, so the\n * stack zig-zags. The images crawl around the bands, dragging spins the\n * tower with inertia, and the pointer turns neighboring bands opposite ways.\n * The canvas is transparent; the parent paints the background, and the\n * inside of the bands fades toward black or white with the theme. Pauses\n * off-screen and in hidden tabs; holds still under `prefers-reduced-motion`.\n */\nexport function createRingTower(\n root: HTMLElement,\n initial: RingTowerOptions = {}\n): RingTowerInstance | null {\n let options: RingTowerOptions = { draggable: true, ...initial }\n\n let renderer: WebGLRenderer\n try {\n renderer = new WebGLRenderer({\n alpha: true,\n antialias: true,\n powerPreference: \"high-performance\",\n })\n } catch {\n return null\n }\n renderer.setPixelRatio(Math.min(Math.max(1, window.devicePixelRatio), 2))\n renderer.outputColorSpace = SRGBColorSpace\n renderer.setClearColor(0x000000, 0)\n const canvas = renderer.domElement\n canvas.style.display = \"block\"\n canvas.style.touchAction = \"pan-y\"\n root.appendChild(canvas)\n\n const scene = new Scene()\n const camera = new PerspectiveCamera(7, 1, 0.01, 1e5)\n camera.position.set(0, 0, 70)\n const tower = new Group()\n tower.rotation.set(-0.2, START_ANGLE, 0.2)\n scene.add(tower)\n\n const geometry = new CylinderGeometry(\n RADIUS,\n RADIUS,\n BAND_HEIGHT,\n SEGMENTS,\n 1,\n true\n )\n const backface = { value: DEFAULT_BACKFACE }\n let dark = resolveDark(options.theme ?? \"auto\")\n let darkMix = dark ? 1 : 0\n const backColor = { value: new Color().setScalar(1 - darkMix) }\n\n type Band = { mesh: Mesh; material: MeshBasicMaterial; texture: Texture }\n let bands: Band[] = []\n let strip: Strip | null = null\n let base: CanvasTexture | null = null\n let tiles = 1\n\n const makeMaterial = (map: Texture) => {\n const material = new MeshBasicMaterial({\n map,\n side: DoubleSide,\n toneMapped: false,\n })\n material.onBeforeCompile = (shader) => {\n shader.uniforms.uBackface = backface\n shader.uniforms.uBackColor = backColor\n shader.fragmentShader = shader.fragmentShader\n .replace(\n \"void main() {\",\n \"uniform float uBackface;\\nuniform vec3 uBackColor;\\nvoid main() {\"\n )\n .replace(\n \"#include \",\n `#include \n if (!gl_FrontFacing) {\n diffuseColor.rgb = mix(diffuseColor.rgb, uBackColor, uBackface);\n }`\n )\n }\n material.customProgramCacheKey = () => \"ring-tower\"\n return material\n }\n\n const clearBands = () => {\n for (const band of bands) {\n tower.remove(band.mesh)\n band.material.dispose()\n if (band.texture !== base) band.texture.dispose()\n }\n bands = []\n }\n\n // Whole tiles around the band keep the seam invisible; the strip is then\n // cropped top and bottom to hold its proportions.\n const fit = (texture: Texture, aspect: number) => {\n const around = (2 * Math.PI * RADIUS) / BAND_HEIGHT\n const whole = Math.floor(around / aspect)\n if (whole >= 1) {\n tiles = whole\n texture.repeat.set(whole, Math.min(1, (aspect * whole) / around))\n texture.offset.y = (1 - texture.repeat.y) / 2\n } else {\n tiles = around / aspect\n texture.repeat.set(tiles, 1)\n texture.offset.y = 0\n }\n }\n\n const buildBands = () => {\n clearBands()\n if (!base) return\n const count = Math.round(numberOr(options.rings, DEFAULT_RINGS, 1, 24))\n const middle = Math.ceil(count / 2) - 1\n for (let i = 0; i < count; i++) {\n // Clones share the image source, so the strip uploads once.\n const texture = i === 0 ? base : base.clone()\n const material = makeMaterial(texture)\n const mesh = new Mesh(geometry, material)\n mesh.rotation.set(0, i * Math.PI * 0.5, TILT)\n mesh.position.set(0, (i - middle) * SPACING, 0)\n tower.add(mesh)\n bands.push({ mesh, material, texture })\n }\n }\n\n const setStrip = (next: Strip | null) => {\n clearBands()\n base?.dispose()\n base = null\n strip = next\n if (!strip) return\n const texture = new CanvasTexture(strip.canvas)\n texture.wrapS = RepeatWrapping\n texture.wrapT = ClampToEdgeWrapping\n texture.generateMipmaps = false\n texture.minFilter = LinearFilter\n texture.magFilter = LinearFilter\n texture.colorSpace = SRGBColorSpace\n texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy())\n fit(texture, strip.aspect)\n base = texture\n buildBands()\n wake()\n }\n\n let loadToken = 0\n let imagesKey = \"\"\n const loadImages = () => {\n const urls = (options.images ?? []).filter(\n (url) => typeof url === \"string\" && url.length > 0\n )\n const key = urls.join(\"\\n\")\n if (key === imagesKey && strip) return\n imagesKey = key\n const token = ++loadToken\n if (urls.length === 0) {\n setStrip(gridStrip())\n return\n }\n void Promise.allSettled(urls.map(loadImage)).then((results) => {\n if (token !== loadToken || destroyed) return\n const loaded = results.flatMap((r) =>\n r.status === \"fulfilled\" ? [r.value] : []\n )\n const next = loaded.length\n ? stitch(loaded, renderer.capabilities.maxTextureSize)\n : null\n setStrip(next ?? gridStrip())\n })\n }\n\n const size = { width: 0, height: 0 }\n const resize = () => {\n const { width, height } = root.getBoundingClientRect()\n size.width = width\n size.height = height\n if (width <= 0 || height <= 0) return\n renderer.setSize(width, height)\n camera.aspect = width / height\n camera.updateProjectionMatrix()\n wake()\n }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n let onScreen = true\n let raf = 0\n let destroyed = false\n let lost = false\n let last = 0\n let crawl = 0\n let angle = START_ANGLE\n let velocity = 0\n let dragging = false\n let dragX = 0\n let pointerX = 0\n let pointerTarget = 0\n\n const visible = () => onScreen && document.visibilityState !== \"hidden\"\n\n function wake() {\n if (raf || destroyed || lost) return\n last = performance.now()\n raf = requestAnimationFrame(tick)\n }\n\n const tick = (now: number) => {\n raf = 0\n if (lost) return\n const dt = Math.min(0.05, Math.max(0, (now - last) / 1000))\n last = now\n if (!visible()) return\n const still = mqReduce.matches\n\n if (!still) {\n // In texture units, so wrapping at 1 never shows a jump.\n crawl += dt * CRAWL * tiles * numberOr(options.speed, DEFAULT_SPEED, 0, 4)\n crawl %= 1\n }\n if (!dragging) {\n if (still) velocity = 0\n angle += velocity * dt\n velocity *= Math.pow(0.95, dt * 60)\n if (Math.abs(velocity) < 1e-4) velocity = 0\n }\n tower.rotation.y = angle\n\n const ease = 1 - Math.exp(-dt * 4)\n const target = still ? 0 : pointerTarget\n pointerX += (target - pointerX) * ease\n const reach =\n (numberOr(options.parallax, DEFAULT_PARALLAX, 0, 90) / 360) * tiles\n bands.forEach((band, i) => {\n const factor = PARALLAX_FACTORS[i % PARALLAX_FACTORS.length]!\n band.texture.offset.x = crawl + pointerX * reach * factor\n })\n\n darkMix += ((dark ? 1 : 0) - darkMix) * (1 - Math.exp(-dt * 6))\n if (Math.abs((dark ? 1 : 0) - darkMix) < 1e-3) darkMix = dark ? 1 : 0\n backColor.value.setScalar(1 - darkMix)\n\n if (size.width > 0 && size.height > 0) renderer.render(scene, camera)\n\n const moving =\n !still ||\n dragging ||\n velocity !== 0 ||\n Math.abs(target - pointerX) > 1e-4 ||\n darkMix !== (dark ? 1 : 0)\n if (moving) raf = requestAnimationFrame(tick)\n }\n\n const raycaster = new Raycaster()\n const ndc = new Vector2()\n const hits = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect()\n if (rect.width === 0 || rect.height === 0) return false\n ndc.set(\n ((e.clientX - rect.left) / rect.width) * 2 - 1,\n -((e.clientY - rect.top) / rect.height) * 2 + 1\n )\n raycaster.setFromCamera(ndc, camera)\n return raycaster.intersectObjects(tower.children, false).length > 0\n }\n\n const setCursor = (value: string) => {\n if (canvas.style.cursor !== value) canvas.style.cursor = value\n }\n\n const onWindowMove = (e: PointerEvent) => {\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const x = (e.clientX - rect.left) / rect.width\n const y = (e.clientY - rect.top) / rect.height\n const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1\n pointerTarget = inside ? clamp(x * 2 - 1, -1, 1) : 0\n wake()\n }\n window.addEventListener(\"pointermove\", onWindowMove, { passive: true })\n\n const onDown = (e: PointerEvent) => {\n if (options.draggable === false || !hits(e)) return\n dragging = true\n dragX = e.clientX\n velocity = 0\n canvas.setPointerCapture(e.pointerId)\n setCursor(\"grabbing\")\n wake()\n }\n const onMove = (e: PointerEvent) => {\n if (dragging) {\n const delta =\n ((e.clientX - dragX) / Math.max(1, size.width)) * Math.PI * 2\n angle += delta\n velocity = delta * 60\n dragX = e.clientX\n return\n }\n setCursor(options.draggable !== false && hits(e) ? \"grab\" : \"\")\n }\n const onUp = (e: PointerEvent) => {\n if (!dragging) return\n dragging = false\n if (canvas.hasPointerCapture(e.pointerId)) {\n canvas.releasePointerCapture(e.pointerId)\n }\n setCursor(hits(e) ? \"grab\" : \"\")\n wake()\n }\n const onLeave = () => {\n if (!dragging) setCursor(\"\")\n }\n canvas.addEventListener(\"pointerdown\", onDown)\n canvas.addEventListener(\"pointermove\", onMove)\n canvas.addEventListener(\"pointerup\", onUp)\n canvas.addEventListener(\"pointercancel\", onUp)\n canvas.addEventListener(\"pointerleave\", onLeave)\n\n const ro = new ResizeObserver(resize)\n ro.observe(root)\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n wake()\n })\n io.observe(root)\n const onVisibility = () => wake()\n document.addEventListener(\"visibilitychange\", onVisibility)\n const onReduce = () => wake()\n mqReduce.addEventListener(\"change\", onReduce)\n\n // three restores its own state; the loop just has to stop and resume.\n const onLost = () => {\n lost = true\n cancelAnimationFrame(raf)\n raf = 0\n }\n const onRestored = () => {\n if (destroyed) return\n lost = false\n if (base) base.needsUpdate = true\n wake()\n }\n canvas.addEventListener(\"webglcontextlost\", onLost)\n canvas.addEventListener(\"webglcontextrestored\", onRestored)\n\n const syncTheme = () => {\n const next = resolveDark(options.theme ?? \"auto\")\n if (next === dark) return\n dark = next\n wake()\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const applyBackface = () => {\n backface.value = numberOr(options.backface, DEFAULT_BACKFACE, 0, 1)\n }\n\n applyBackface()\n resize()\n loadImages()\n wake()\n\n return {\n setOptions(next) {\n const prevRings = options.rings\n options = { ...options, ...next }\n applyBackface()\n if (options.draggable === false) {\n dragging = false\n setCursor(\"\")\n }\n if (options.rings !== prevRings) buildBands()\n loadImages()\n syncTheme()\n wake()\n },\n destroy() {\n destroyed = true\n cancelAnimationFrame(raf)\n raf = 0\n ro.disconnect()\n io.disconnect()\n mo.disconnect()\n document.removeEventListener(\"visibilitychange\", onVisibility)\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n window.removeEventListener(\"pointermove\", onWindowMove)\n canvas.removeEventListener(\"pointerdown\", onDown)\n canvas.removeEventListener(\"pointermove\", onMove)\n canvas.removeEventListener(\"pointerup\", onUp)\n canvas.removeEventListener(\"pointercancel\", onUp)\n canvas.removeEventListener(\"pointerleave\", onLeave)\n canvas.removeEventListener(\"webglcontextlost\", onLost)\n canvas.removeEventListener(\"webglcontextrestored\", onRestored)\n clearBands()\n base?.dispose()\n geometry.dispose()\n renderer.dispose()\n renderer.forceContextLoss()\n canvas.remove()\n },\n }\n}\n\nexport type RingTowerProps = RingTowerOptions & {\n className?: string\n}\n\n/**\n * A tower of tilted bands wrapped in your images. The images crawl around\n * the bands, dragging spins the tower, and the pointer turns neighboring\n * bands opposite ways. Transparent; the parent paints the background.\n * Theme-aware.\n */\nexport function RingTower({\n className,\n images,\n rings = DEFAULT_RINGS,\n speed = DEFAULT_SPEED,\n draggable = true,\n parallax = DEFAULT_PARALLAX,\n backface = DEFAULT_BACKFACE,\n theme = \"auto\",\n}: RingTowerProps) {\n const rootRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n if (!root) return\n instanceRef.current = createRingTower(root, {\n images,\n rings,\n speed,\n draggable,\n parallax,\n backface,\n theme,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n images,\n rings,\n speed,\n draggable,\n parallax,\n backface,\n theme,\n })\n }, [images, rings, speed, draggable, parallax, backface, theme])\n\n return (\n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ring-tower.tsx" } diff --git a/registry/ring-tower/ring-tower-demo.tsx b/registry/ring-tower/ring-tower-demo.tsx index e47585f..799a16d 100644 --- a/registry/ring-tower/ring-tower-demo.tsx +++ b/registry/ring-tower/ring-tower-demo.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react" +import { Button } from "@/components/ui/button" import { ComponentControls, ControlSlider, @@ -34,7 +35,13 @@ function card(body: string) { return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` } -const text = (x: number, y: number, size: number, value: string, weight = 400) => +const text = ( + x: number, + y: number, + size: number, + value: string, + weight = 400 +) => `${value}` const lines = (x: number, y: number, widths: number[]) => @@ -42,7 +49,21 @@ const lines = (x: number, y: number, widths: number[]) => .map((w, i) => ``) .join("") -const DEMO_IMAGES = [ +const PHOTOS = [ + "1506905925346-21bda4d32df4", + "1501785888041-af3ef285b470", + "1441974231531-c6227db76b6e", + "1470071459604-3b5ec3a7fe05", + "1507525428034-b723cf961d3e", + "1472214103451-9374bd1c798e", + "1433086966358-54859d0ed716", + "1469474968028-56623f02e42e", +].map( + (id) => + `https://images.unsplash.com/photo-${id}?w=1200&h=800&fit=crop&q=75&auto=format` +) + +const CARDS = [ card( text(56, 150, 40, "Ship the sharper default", 500) + lines(56, 196, [500, 470, 380]) + @@ -98,6 +119,14 @@ const DEMO_IMAGES = [ ), ] +type Source = "photos" | "cards" | "none" + +const SOURCES: { id: Source; label: string; images?: string[] }[] = [ + { id: "photos", label: "Photos", images: PHOTOS }, + { id: "cards", label: "Cards", images: CARDS }, + { id: "none", label: "None" }, +] + export function RingTowerDemo() { const defaults = useMemo( () => ({ @@ -106,7 +135,7 @@ export function RingTowerDemo() { parallax: DEFAULT_PARALLAX, backface: DEFAULT_BACKFACE, draggable: true, - images: true, + source: "photos" as Source, }), [] ) @@ -114,7 +143,7 @@ export function RingTowerDemo() { const { props, updateProp, resetProps, hasChanges } = usePreviewProps(defaults) - const images = props.images ? DEMO_IMAGES : undefined + const images = SOURCES.find((s) => s.id === props.source)?.images return ( <> @@ -122,7 +151,7 @@ export function RingTowerDemo() { title="Ring Tower" stageClassName="min-h-0 overflow-hidden p-0" > -
+
+ {props.source === "photos" ? ( + + Photos from Unsplash + + ) : null}
@@ -143,9 +182,10 @@ export function RingTowerDemo() { onReset={resetProps} component="RingTower" snippetProps={{ - images: props.images - ? ["/bands/0.jpg", "/bands/1.jpg", "/bands/2.jpg"] - : undefined, + images: + props.source === "none" + ? undefined + : ["/bands/0.jpg", "/bands/1.jpg", "/bands/2.jpg"], rings: props.rings === DEFAULT_RINGS ? undefined : props.rings, speed: props.speed === DEFAULT_SPEED ? undefined : props.speed, parallax: @@ -155,6 +195,23 @@ export function RingTowerDemo() { draggable: props.draggable ? undefined : false, }} > +
+ Images +
+ {SOURCES.map((item) => ( + + ))} +
+
updateProp("draggable", v)} /> - updateProp("images", v)} - /> ) diff --git a/registry/ring-tower/ring-tower-vanilla.ts b/registry/ring-tower/ring-tower-vanilla.ts index ff28879..0da0120 100644 --- a/registry/ring-tower/ring-tower-vanilla.ts +++ b/registry/ring-tower/ring-tower-vanilla.ts @@ -1,6 +1,7 @@ import { CanvasTexture, ClampToEdgeWrapping, + Color, CylinderGeometry, DoubleSide, Group, @@ -17,6 +18,8 @@ import { type Texture, } from "three" +export type RingTowerTheme = "light" | "dark" | "auto" + export type RingTowerOptions = { /** * Image URLs drawn side by side around every band. Cross-origin images @@ -34,8 +37,13 @@ export type RingTowerOptions = { * opposite way. Default `24`, 0 to 90. `0` keeps every band in step. */ parallax?: number - /** How dark the inside of the bands is, 0 to 1. Default `0.86`. */ + /** + * How far the inside of the bands fades toward the page, 0 to 1: black in + * dark mode, white in light mode. Default `0.86`. + */ backface?: number + /** `"auto"` follows the page. Default `"auto"`. */ + theme?: RingTowerTheme } export type RingTowerInstance = { @@ -68,6 +76,24 @@ function numberOr(value: unknown, fallback: number, min: number, max: number) { return clamp(value, min, max) } +/** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ +export function isDarkTheme(): boolean { + if (typeof document === "undefined") return false + const root = document.documentElement + if (root.classList.contains("dark")) return true + if (root.classList.contains("light")) return false + const dataTheme = root.getAttribute("data-theme") + if (dataTheme === "dark") return true + if (dataTheme === "light") return false + return window.matchMedia("(prefers-color-scheme: dark)").matches +} + +export function resolveDark(theme: RingTowerTheme): boolean { + if (theme === "dark") return true + if (theme === "light") return false + return isDarkTheme() +} + function loadImage(url: string) { return new Promise((resolve, reject) => { const img = new Image() @@ -145,7 +171,8 @@ function gridStrip(): Strip | null { * the vuemail.dev hero. Each band leans a quarter turn from the last, so the * stack zig-zags. The images crawl around the bands, dragging spins the * tower with inertia, and the pointer turns neighboring bands opposite ways. - * The canvas is transparent; the parent paints the background. Pauses + * The canvas is transparent; the parent paints the background, and the + * inside of the bands fades toward black or white with the theme. Pauses * off-screen and in hidden tabs; holds still under `prefers-reduced-motion`. */ export function createRingTower( @@ -188,6 +215,9 @@ export function createRingTower( true ) const backface = { value: DEFAULT_BACKFACE } + let dark = resolveDark(options.theme ?? "auto") + let darkMix = dark ? 1 : 0 + const backColor = { value: new Color().setScalar(1 - darkMix) } type Band = { mesh: Mesh; material: MeshBasicMaterial; texture: Texture } let bands: Band[] = [] @@ -203,13 +233,17 @@ export function createRingTower( }) material.onBeforeCompile = (shader) => { shader.uniforms.uBackface = backface + shader.uniforms.uBackColor = backColor shader.fragmentShader = shader.fragmentShader - .replace("void main() {", "uniform float uBackface;\nvoid main() {") + .replace( + "void main() {", + "uniform float uBackface;\nuniform vec3 uBackColor;\nvoid main() {" + ) .replace( "#include ", `#include if (!gl_FrontFacing) { - diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.0), uBackface); + diffuseColor.rgb = mix(diffuseColor.rgb, uBackColor, uBackface); }` ) } @@ -370,13 +404,18 @@ export function createRingTower( band.texture.offset.x = crawl + pointerX * reach * factor }) + darkMix += ((dark ? 1 : 0) - darkMix) * (1 - Math.exp(-dt * 6)) + if (Math.abs((dark ? 1 : 0) - darkMix) < 1e-3) darkMix = dark ? 1 : 0 + backColor.value.setScalar(1 - darkMix) + if (size.width > 0 && size.height > 0) renderer.render(scene, camera) const moving = !still || dragging || velocity !== 0 || - Math.abs(target - pointerX) > 1e-4 + Math.abs(target - pointerX) > 1e-4 || + darkMix !== (dark ? 1 : 0) if (moving) raf = requestAnimationFrame(tick) } @@ -419,7 +458,8 @@ export function createRingTower( } const onMove = (e: PointerEvent) => { if (dragging) { - const delta = ((e.clientX - dragX) / Math.max(1, size.width)) * Math.PI * 2 + const delta = + ((e.clientX - dragX) / Math.max(1, size.width)) * Math.PI * 2 angle += delta velocity = delta * 60 dragX = e.clientX @@ -472,6 +512,20 @@ export function createRingTower( canvas.addEventListener("webglcontextlost", onLost) canvas.addEventListener("webglcontextrestored", onRestored) + const syncTheme = () => { + const next = resolveDark(options.theme ?? "auto") + if (next === dark) return + dark = next + wake() + } + const mo = new MutationObserver(syncTheme) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme", "style"], + }) + const mqDark = window.matchMedia("(prefers-color-scheme: dark)") + mqDark.addEventListener("change", syncTheme) + const applyBackface = () => { backface.value = numberOr(options.backface, DEFAULT_BACKFACE, 0, 1) } @@ -492,6 +546,7 @@ export function createRingTower( } if (options.rings !== prevRings) buildBands() loadImages() + syncTheme() wake() }, destroy() { @@ -500,8 +555,10 @@ export function createRingTower( raf = 0 ro.disconnect() io.disconnect() + mo.disconnect() document.removeEventListener("visibilitychange", onVisibility) mqReduce.removeEventListener("change", onReduce) + mqDark.removeEventListener("change", syncTheme) window.removeEventListener("pointermove", onWindowMove) canvas.removeEventListener("pointerdown", onDown) canvas.removeEventListener("pointermove", onMove) diff --git a/registry/ring-tower/ring-tower.svelte b/registry/ring-tower/ring-tower.svelte index d237275..790f5ce 100644 --- a/registry/ring-tower/ring-tower.svelte +++ b/registry/ring-tower/ring-tower.svelte @@ -29,6 +29,7 @@ draggable = true, parallax = DEFAULT_PARALLAX, backface = DEFAULT_BACKFACE, + theme = "auto", }: Props = $props() let root: HTMLDivElement | undefined = $state() @@ -43,6 +44,7 @@ draggable, parallax, backface, + theme, }) return () => { instance?.destroy() @@ -58,6 +60,7 @@ draggable, parallax, backface, + theme, }) }) diff --git a/registry/ring-tower/ring-tower.tsx b/registry/ring-tower/ring-tower.tsx index 6a8723b..edc8d2a 100644 --- a/registry/ring-tower/ring-tower.tsx +++ b/registry/ring-tower/ring-tower.tsx @@ -20,7 +20,11 @@ export { DEFAULT_RINGS, DEFAULT_SPEED, } from "./ring-tower-vanilla" -export type { RingTowerInstance, RingTowerOptions } from "./ring-tower-vanilla" +export type { + RingTowerInstance, + RingTowerOptions, + RingTowerTheme, +} from "./ring-tower-vanilla" export type RingTowerProps = RingTowerOptions & { className?: string @@ -30,6 +34,7 @@ export type RingTowerProps = RingTowerOptions & { * A tower of tilted bands wrapped in your images. The images crawl around * the bands, dragging spins the tower, and the pointer turns neighboring * bands opposite ways. Transparent; the parent paints the background. + * Theme-aware. */ export function RingTower({ className, @@ -39,6 +44,7 @@ export function RingTower({ draggable = true, parallax = DEFAULT_PARALLAX, backface = DEFAULT_BACKFACE, + theme = "auto", }: RingTowerProps) { const rootRef = useRef(null) const instanceRef = useRef(null) @@ -53,6 +59,7 @@ export function RingTower({ draggable, parallax, backface, + theme, }) return () => { instanceRef.current?.destroy() @@ -70,8 +77,9 @@ export function RingTower({ draggable, parallax, backface, + theme, }) - }, [images, rings, speed, draggable, parallax, backface]) + }, [images, rings, speed, draggable, parallax, backface, theme]) return (
+
) diff --git a/skills/23rd/conventions.md b/skills/23rd/conventions.md index 137c76b..d746f37 100644 --- a/skills/23rd/conventions.md +++ b/skills/23rd/conventions.md @@ -90,6 +90,7 @@ Vanilla: - No React, no Svelte, no `@/` imports. - Export an options type, an instance type `{ setOptions, destroy }`, and `create(canvas | root, options)`. +- Every component works in light and dark mode. Expose `theme?: "light" | "dark" | "auto"` (default `"auto"`) and give anything theme-sensitive (ink, fades, backfaces, glows) a light and a dark value. Watch `html` attributes and `prefers-color-scheme` so `auto` follows the page live. - Theme helper: `html.dark` / `html.light`, then `data-theme`, then `prefers-color-scheme`. Copy `isDarkTheme` from `logo-burst-vanilla.ts` rather than inventing a new contract. - Honor `prefers-reduced-motion: reduce`. - Custom `colors` or `color`, when set, win over the theme palette. @@ -97,6 +98,7 @@ Vanilla: Demos: - `*-demo.tsx` is for the docs site only. It is not a registry file. +- Demo stages and docs examples use theme tokens (`bg-background`, `from-background`, `text-foreground`), never a hard-coded black or white stage. Check the demo in both themes before shipping. - Use `ComponentPreview`, `ComponentControls`, and `usePreviewProps` the way `shader-gradient-demo.tsx` does. - `gooey-color-picker` has no demo file; the MDX renders `` inside `ComponentPreview`. Either pattern is acceptable. Do not publish the demo. @@ -171,6 +173,7 @@ pnpm typecheck ## Do not - Publish a React-only item. Tests require a Svelte port. +- Ship a component that only looks right in one theme. - Import `./-vanilla` from a file that is not the wrapper the build inlines. - Put site chrome (`components/docs-shell.tsx`, `components/ui/button.tsx`) into the registry. `components/ui/*` in this repo is the docs site’s shadcn kit, not the published set. - Add a `variant` prop unless the design really has discrete materials. Only `live-orb` has one.