three
Distortion Sphere
An icosahedron-detail sphere driven by a GPU vertex shader — two octaves of Simplex noise displace each vertex along its normal, breathing slowly at rest and sharpening into a faster, more aggressive distortion on hover/focus. A fresnel rim baked into the fragment shader from --primary (surface) + --foreground (edge glow) makes it feel native to the design system, and a 'tension release' on pointer-leave overshoots amplitude before settling for real physicality. Composed over canvas-wrapper, which owns the R3F lifecycle: WebGL feature-detect, lazy mount, reduced-motion / no-WebGL poster, and GPU dispose. The scene is dynamic-imported ssr:false (R3F can't SSR) and reads its color tokens off the live DOM so it themes with light/dark.
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/distortion-sphereUsage
<DistortionSphere />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| size | number | 320 | Diameter in pixels — drives the pixel box; canvas-wrapper fills it. Default 320. |
| idleSpeed | number | 0.4 | Time-uniform multiplier at rest (how fast the noise field evolves). Default 0.4. |
| idleAmplitude | number | 0.25 | Vertex displacement magnitude at rest (0–1). Default 0.25. |
| hoverAmplitude | number | 0.55 | Target amplitude on pointer-enter / focus. Default 0.55. |
| hoverSpeed | number | 1.2 | Time-uniform multiplier on hover / focus. Default 1.2. |
| color | string | — | CSS color for the shader base color. Defaults to var(--primary). |
| rimColor | string | — | Fresnel rim accent color. Defaults to var(--foreground). |
| wireframe | boolean | false | Render as wireframe — debug or creative variant. Default false. |
| className | string | — | Forwarded to the focusable root div for layout control. |
| aria-label | string | Animated distortion sphere | Accessible label for the decorative region. Default "Animated distortion sphere". |
Source
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { useReducedMotion } from "motion/react";
import { CanvasWrapper } from "@/registry/three/canvas-wrapper/canvas-wrapper";
import { DistortionSpherePoster } from "./distortion-sphere-poster";
// R3F cannot SSR — load the <Canvas> scene client-side only. The poster shows
// while it loads and on every guarded path (no WebGL / reduced motion / offscreen).
const DistortionSphereScene = dynamic(() => import("./distortion-sphere-scene"), {
ssr: false,
loading: () => <DistortionSpherePoster />,
});
export interface DistortionSphereProps {
/** Diameter in pixels — drives the pixel box; canvas-wrapper fills it. Default 320. */
size?: number;
/** Time-uniform multiplier at rest (how fast the noise field evolves). Default 0.4. */
idleSpeed?: number;
/** Vertex displacement magnitude at rest (0–1). Default 0.25. */
idleAmplitude?: number;
/** Target amplitude on pointer-enter / focus. Default 0.55. */
hoverAmplitude?: number;
/** Time-uniform multiplier on hover / focus. Default 1.2. */
hoverSpeed?: number;
/** CSS color for the shader base color. Defaults to var(--primary). */
color?: string;
/** Fresnel rim accent color. Defaults to var(--foreground). */
rimColor?: string;
/** Render as wireframe — debug or creative variant. Default false. */
wireframe?: boolean;
/** Forwarded to the focusable root div for layout control. */
className?: string;
/** Accessible label for the decorative region. Default "Animated distortion sphere". */
"aria-label"?: string;
}
/**
* An icosahedron-detail sphere driven by a GPU vertex shader (two octaves of
* Simplex noise displacing each vertex along its normal). It breathes slowly at
* rest and sharpens into a faster, more aggressive distortion on interaction.
*
* Two differentiators baked into the fragment shader so it feels native to the
* design system, not a foreign Three.js object:
* 1. a fresnel rim using `--primary` (base) + `--foreground` (edge glow);
* 2. a "tension release" on pointer-leave — amplitude briefly overshoots before
* settling back to idle, giving the sphere a physicality flat easing can't.
*
* Composed over `@bottega/canvas-wrapper`, which owns the R3F canvas lifecycle:
* WebGL feature-detect, IntersectionObserver lazy-mount, reduced-motion / no-WebGL
* poster fallback, and GPU dispose on unmount.
*
* **Purely decorative.** Do not convey meaning through the animation alone — if
* you use the sphere as a trigger, compose it inside an accessible button/anchor
* in the parent. Color contrast for any overlaid text is not this component's
* responsibility.
*
* Tokens: `--primary` (surface), `--foreground` (rim), `--background` (clear),
* `--ring` (focus outline), `--radius` (container corners).
*/
export function DistortionSphere({
size = 320,
idleSpeed = 0.4,
idleAmplitude = 0.25,
hoverAmplitude = 0.55,
hoverSpeed = 1.2,
color,
rimColor,
wireframe = false,
className,
"aria-label": ariaLabel = "Animated distortion sphere",
}: DistortionSphereProps) {
// Mount-gate + null-safe reduced-motion check (contract snippet). Server + first
// client render are static (frozen) → no hydration mismatch / React #418.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
// frozen=true holds the GPU on a single static frame (no time tick, amplitude
// pinned to idle*0.5) so the form still reads — it may carry brand identity.
const frozen = !mounted || reduce;
return (
// Focusable, labelled region. The focus-visible ring is drawn in CSS OUTSIDE
// the canvas (it survives GPU rendering); never suppressed. tabIndex makes
// keyboard focus trigger the hover ramp via the scene's focus listener.
<div
data-distortion-root=""
role="img"
aria-label={ariaLabel}
tabIndex={0}
className={[
"relative inline-block rounded-[var(--radius)] outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
]
.filter(Boolean)
.join(" ")}
style={{ width: size, height: size }}
>
<CanvasWrapper
className="h-full w-full"
aspect="1 / 1"
minHeight="100%"
poster={<DistortionSpherePoster />}
>
{/* aria-hidden is applied by canvas-wrapper on the live scene host. */}
<DistortionSphereScene
idleSpeed={idleSpeed}
idleAmplitude={idleAmplitude}
hoverAmplitude={hoverAmplitude}
hoverSpeed={hoverSpeed}
color={color}
rimColor={rimColor}
wireframe={wireframe}
frozen={frozen}
/>
</CanvasWrapper>
</div>
);
}
export default DistortionSphere;
Dependencies
- three
- @react-three/fiber
- @bottega/canvas-wrapper
- @bottega/tokens