bottega

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):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/distortion-sphere

Usage

Usagetsx
<DistortionSphere />

Props

PropTypeDefaultDescription
sizenumber320Diameter in pixels — drives the pixel box; canvas-wrapper fills it. Default 320.
idleSpeednumber0.4Time-uniform multiplier at rest (how fast the noise field evolves). Default 0.4.
idleAmplitudenumber0.25Vertex displacement magnitude at rest (0–1). Default 0.25.
hoverAmplitudenumber0.55Target amplitude on pointer-enter / focus. Default 0.55.
hoverSpeednumber1.2Time-uniform multiplier on hover / focus. Default 1.2.
colorstringCSS color for the shader base color. Defaults to var(--primary).
rimColorstringFresnel rim accent color. Defaults to var(--foreground).
wireframebooleanfalseRender as wireframe — debug or creative variant. Default false.
classNamestringForwarded to the focusable root div for layout control.
aria-labelstringAnimated distortion sphereAccessible label for the decorative region. Default "Animated distortion sphere".

Source

distortion-sphere.tsxtsx
"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