Prism
← All primitives

ForecastCone

3D

A generative 'futures flux': thousands of GPU particles stream out of 'now' and swirl forward through a time-evolving curl field, fanning into a widening cloud of possibility — a bright median spine, diffuse wildcards, colour grading grounded→imagined. Procedural in the vertex shader, never the same frame twice.

Render via next/dynamic ssr:false. Self-contained; `spread` controls how uncertain the future is.

$npx shadcn@latest add https://prism.icglabs.co/r/forecast-cone.json
Dependencies:three@react-three/fiber@react-three/postprocessing
View raw manifest →

components/prism/ForecastCone.tsx
"use client";

import { useMemo, useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { EffectComposer, Bloom } from "@react-three/postprocessing";
import * as THREE from "three";
import { useDeviceTier } from "@/lib/useDeviceTier";
import { AutoDpr, PauseWhenOffscreen } from "@/components/showcase/CanvasPerf";

/**
 * ForecastCone — a generative "futures flux". Thousands of GPU particles stream
 * out of a single "now" and swirl forward through a time-evolving curl field,
 * fanning into a widening cloud of possibility (uncertainty grows downstream).
 * A tight bright spine = the median forecast; the diffuse halo = the wildcards.
 * Colour grades grounded→imagined as paths diverge. Fully procedural in the
 * vertex shader (no CPU loop), so it's smooth at scale and never the same frame
 * twice. Built for Sky's forecast-with-uncertainty: `spread` = how wild tomorrow is.
 */
export type ForecastConeProps = {
  count?: number;
  spread?: number;
  /** [grounded, imagined] hex — the divergence gradient. */
  colors?: [string, string];
  className?: string;
};

const VERT = /* glsl */ `
attribute float aSeed;
uniform float uTime;
uniform float uSpeed;
uniform float uSpread;
uniform float uSpan;
uniform float uSize;
uniform float uPixelRatio;
uniform vec3 uGrounded;
uniform vec3 uImagined;
varying vec3 vColor;
varying float vAlpha;

void main() {
  float ph = fract(uTime * uSpeed * (0.5 + 0.5 * aSeed) + aSeed * 7.13);
  float fwd = ph * uSpan;                       // distance into the future
  float grow = pow(ph, 0.8) * uSpread;          // uncertainty widens downstream
  float wild = step(0.22, aSeed);               // ~22% hug the median spine
  float a1 = aSeed * 6.28318;
  float sw = uTime * 0.35 + a1;
  // layered trig ~ a curl field that morphs over time → swirling streams
  float ox = sin(sw + fwd * 0.7) * 0.7 + sin(a1 * 3.1 + fwd * 1.6) * 0.35;
  float oy = cos(sw * 1.13 + fwd * 0.8) * 0.7 + cos(a1 * 2.3 + fwd * 1.2) * 0.35;
  vec3 pos = vec3(ox * grow * wild, oy * grow * wild, -fwd + uSpan * 0.5);

  vec4 mv = modelViewMatrix * vec4(pos, 1.0);
  gl_Position = projectionMatrix * mv;
  gl_PointSize = uSize * uPixelRatio * (0.55 + 0.6 * (1.0 - ph)) * (1.0 / -mv.z);

  float div = grow * wild;
  vColor = mix(uGrounded, uImagined, clamp(ph * 0.55 + div * 0.18, 0.0, 1.0));
  vAlpha = smoothstep(0.0, 0.06, ph) * smoothstep(1.0, 0.7, ph) * (wild > 0.5 ? 0.4 : 1.0);
}
`;

const FRAG = /* glsl */ `
precision mediump float;
varying vec3 vColor;
varying float vAlpha;
void main() {
  vec2 c = gl_PointCoord - 0.5;
  float d = dot(c, c);
  if (d > 0.25) discard;
  float a = smoothstep(0.25, 0.0, d) * vAlpha;
  gl_FragColor = vec4(vColor, a);
}
`;

function Flux({
  count,
  spread,
  colors,
  reduced,
}: {
  count: number;
  spread: number;
  colors: [string, string];
  reduced: boolean;
}) {
  const pts = useRef<THREE.Points>(null);
  const mat = useRef<THREE.ShaderMaterial>(null);

  const geometry = useMemo(() => {
    const position = new Float32Array(count * 3);
    const aSeed = new Float32Array(count);
    for (let i = 0; i < count; i++) aSeed[i] = Math.random();
    const g = new THREE.BufferGeometry();
    g.setAttribute("position", new THREE.BufferAttribute(position, 3));
    g.setAttribute("aSeed", new THREE.BufferAttribute(aSeed, 1));
    return g;
  }, [count]);

  const uniforms = useMemo(
    () => ({
      uTime: { value: 0 },
      uSpeed: { value: 0.12 },
      uSpread: { value: spread },
      uSpan: { value: 9 },
      uSize: { value: 58 },
      uPixelRatio: { value: 1 },
      uGrounded: { value: new THREE.Color(colors[0]) },
      uImagined: { value: new THREE.Color(colors[1]) },
    }),
    [spread, colors]
  );

  useFrame((state, delta) => {
    const m = mat.current;
    if (m) {
      if (!reduced) m.uniforms.uTime.value += delta;
      else m.uniforms.uTime.value = 6.0; // a fixed, fully-formed frame
      m.uniforms.uPixelRatio.value = Math.min(state.gl.getPixelRatio(), 2);
    }
    if (pts.current && !reduced) pts.current.rotation.z += delta * 0.02;
  });

  return (
    <points ref={pts} geometry={geometry}>
      <shaderMaterial
        ref={mat}
        uniforms={uniforms}
        vertexShader={VERT}
        fragmentShader={FRAG}
        transparent
        depthWrite={false}
        blending={THREE.AdditiveBlending}
      />
    </points>
  );
}

export function ForecastCone({
  count = 7000,
  spread = 1.6,
  colors = ["#1fd4e6", "#ff3d81"],
  className,
}: ForecastConeProps) {
  const reduced =
    typeof window !== "undefined" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const q = useDeviceTier();
  const n = Math.max(1500, Math.round(count * q.scale));

  return (
    <div className={className} style={{ width: "100%", height: "100%" }}>
      <Canvas
        dpr={q.dpr}
        gl={{ antialias: false, powerPreference: "high-performance" }}
        camera={{ position: [0, 0.35, 5], fov: 55 }}
      >
        <color attach="background" args={["#06060e"]} />
        {/* the bright "now" core */}
        <mesh position={[0, 0, 4.5]}>
          <sphereGeometry args={[0.05, 16, 16]} />
          <meshBasicMaterial color={colors[0]} toneMapped={false} />
        </mesh>
        <Flux count={n} spread={spread} colors={colors} reduced={reduced} />
        <AutoDpr />
        <PauseWhenOffscreen />
        {q.bloom && (
          <EffectComposer>
            <Bloom intensity={1.2} luminanceThreshold={0} luminanceSmoothing={0.3} mipmapBlur radius={0.7} />
          </EffectComposer>
        )}
      </Canvas>
    </div>
  );
}
Live demo — read-only. Every section is a real, copyable primitive.