Skip to content

Signal Foundry · 3D hero

site/examples/three-hero.html

three.jsdarktokens at runtime
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VERSEVA Design · Example: 3D hero</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500..700&family=Manrope:wght@400..700&family=JetBrains+Mono:wght@400..600&display=swap">
<link rel="stylesheet" href="../../tokens.css?v=1p3">
<link rel="stylesheet" href="../../components.css?v=1p3">
<style>
  * { margin: 0; box-sizing: border-box; }
  body { background: var(--bg); color: var(--text-1); font-family: var(--font-sans); font-size: 15px; line-height: 1.6; }
  .crumb { position: fixed; top: 0; left: 0; right: 0; z-index: 5; display: flex; align-items: center; gap: var(--s-3); padding: var(--s-3) var(--s-5); font-size: 12.5px; color: var(--text-3); }
  .crumb a { color: var(--text-2); text-decoration: none; font-family: var(--font-display); font-weight: var(--display-weight); }
  .stage { position: relative; height: 100vh; overflow: hidden; }
  #scene { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
  .overlay { position: relative; z-index: 2; height: 100%; display: flex; flex-direction: column; justify-content: center; max-width: var(--container-content); margin: 0 auto; padding: 0 var(--s-6); pointer-events: none; }
  .overlay .kick { font-family: var(--font-mono); font-size: 12px; color: var(--text-3); padding-bottom: var(--s-3); }
  .overlay h1 { font-family: var(--font-display); font-weight: var(--display-weight); font-size: clamp(40px, 7vw, 84px); letter-spacing: -0.03em; line-height: 1.02; max-width: 14ch; }
  .overlay p { color: var(--text-2); font-size: 17px; max-width: 44ch; padding-top: var(--s-4); }
  .overlay .cta { display: flex; gap: var(--s-3); padding-top: var(--s-5); pointer-events: auto; }
  .hint { position: absolute; z-index: 2; left: 50%; bottom: var(--s-5); transform: translateX(-50%); font-family: var(--font-mono); font-size: 11px; color: var(--text-3); white-space: nowrap; padding: var(--s-1) var(--s-3); border-radius: var(--r-pill); background: color-mix(in srgb, var(--bg) 90%, transparent); }
  /* A directional scrim between the scene and the copy: the ground colour holds behind the text
     and fades out over the knot, so the copy keeps its contrast in both themes. */
  .stage::before { content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(90deg, color-mix(in srgb, var(--bg) 90%, transparent) 0%, color-mix(in srgb, var(--bg) 90%, transparent) 58%, transparent 84%); }
  @media (max-width: 760px) {
    .stage::before { background: linear-gradient(180deg, transparent 8%, color-mix(in srgb, var(--bg) 90%, transparent) 22%, color-mix(in srgb, var(--bg) 90%, transparent) 78%, transparent 92%); }
  }
  .below { max-width: var(--container-content); margin: 0 auto; padding: var(--s-9) var(--s-6); display: grid; gap: var(--s-5); }
  .below h2 { font-family: var(--font-display); font-weight: var(--heading-weight); font-size: 24px; letter-spacing: -0.015em; }
  .trio { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--s-4); }
  footer { max-width: var(--container-content); margin: 0 auto; padding: 0 var(--s-6) var(--s-7); display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--s-3); color: var(--text-3); font-size: 12.5px; }
  .badge-line { font-family: var(--font-display); font-weight: var(--display-weight); font-size: 13px; color: var(--text-2); }
  .crumb a { display: inline-flex; align-items: center; gap: 8px; }
  .crumb .vmark { width: 16px; height: 16px; flex: none; }
  .crumb .codelink { margin-left: auto; }
</style>
</head>
<body>
<div class="crumb"><a href="../index.html"><svg class="vmark" viewBox="0 0 768 775.92" aria-hidden="true"><defs><clipPath id="vmk-a"><path d="M400 277h358v396.4H400z"/></clipPath><clipPath id="vmk-b"><path d="M10 97h550v576.4H10z"/></clipPath><clipPath id="vmk-c"><path d="M10 94.5h358V491H10z"/></clipPath><clipPath id="vmk-d"><path d="M208 94.5h550V671H208z"/></clipPath></defs><g clip-path="url(#vmk-a)"><path fill="currentColor" opacity=".55" d="M 558.605469 278.128906 C 555.410156 277.984375 552.164062 277.90625 548.96875 277.90625 C 466.261719 277.90625 407.433594 327.675781 401.222656 333.144531 L 400.800781 333.59375 C 400.800781 333.59375 587.386719 406.570312 718.046875 673.386719 C 718.046875 673.386719 767.757812 550.011719 756.207031 509.574219 C 740.042969 453 663.917969 362.328125 584.683594 279.359375 L 558.605469 278.128906"/></g><g clip-path="url(#vmk-b)"><path fill="currentColor" d="M 49.953125 673.386719 C 49.953125 673.386719 262.128906 253.59375 559.761719 253.59375 C 497.894531 190.457031 437.765625 134.679688 406.402344 106.136719 C 393.699219 94.574219 374.308594 94.574219 361.601562 106.136719 C 285.859375 175.0625 42.257812 402.917969 11.796875 509.574219 C 0.242188 550.011719 49.953125 673.386719 49.953125 673.386719"/></g><g clip-path="url(#vmk-c)"><path fill="currentColor" opacity=".55" d="M 209.394531 489.785156 C 212.589844 489.929688 215.835938 490.007812 219.03125 490.007812 C 301.738281 490.007812 360.566406 440.238281 366.777344 434.765625 L 367.199219 434.316406 C 367.199219 434.316406 180.613281 361.34375 49.953125 94.523438 C 49.953125 94.523438 0.242188 217.902344 11.792969 258.335938 C 27.957031 314.914062 104.082031 405.585938 183.316406 488.554688 L 209.394531 489.785156"/></g><g clip-path="url(#vmk-d)"><path fill="currentColor" d="M 718.046875 94.523438 C 718.046875 94.523438 505.871094 514.316406 208.238281 514.316406 C 270.105469 577.453125 330.234375 633.230469 361.597656 661.773438 C 374.300781 673.335938 393.691406 673.335938 406.398438 661.773438 C 482.140625 592.847656 725.742188 364.992188 756.203125 258.335938 C 767.757812 217.902344 718.046875 94.523438 718.046875 94.523438"/></g></svg><span>VERSEVA Design</span></a><span>examples</span><span>3D hero · Three.js</span><a class="btn btn-secondary sm codelink" href="../code.html?f=examples/three-hero.html">Code</a></div>

<section class="stage">
  <canvas id="scene"></canvas>
  <div class="overlay">
    <div class="kick">SIGNAL FOUNDRY · WEBGL ON THE SIGNATURE</div>
    <h1>Depth is a material too</h1>
    <p>The scene reads every color from the tokens at runtime: ground, glass, and one energetic red rim. Drag to orbit; the signature holds in 3D.</p>
    <div class="cta">
      <button class="btn btn-primary lg" type="button" id="cta-toast">Request access</button>
      <a class="btn btn-secondary lg" href="#how">How it's built</a>
    </div>
  </div>
  <div class="hint">drag to orbit · scroll for the doctrine</div>
</section>

<section class="below" id="how">
  <h2>How the scene stays on-signature</h2>
  <div class="trio">
    <div class="card"><div class="hd"><h4>Tokens at runtime</h4></div><div class="bd">Materials read <span style="font-family: var(--font-mono); font-size: 12.5px">--bg</span>, <span style="font-family: var(--font-mono); font-size: 12.5px">--surface-2</span> and <span style="font-family: var(--font-mono); font-size: 12.5px">--accent</span> with getComputedStyle. No hex ever enters the scene file.</div></div>
    <div class="card"><div class="hd"><h4>One accent in 3D</h4></div><div class="bd">The red budget is one rim light and the particle edge. The mesh itself stays matte dark glass.</div></div>
    <div class="card"><div class="hd"><h4>Reduced motion honored</h4></div><div class="bd">With reduced motion set, the idle rotation stops and the scene becomes a still composition. Orbit stays manual.</div></div>
  </div>
</section>

<footer>
  <span>Signal Foundry is fictional; this page is a VERSEVA Design example.</span>
  <span class="badge-line">Built on VERSEVA Design</span>
</footer>

<div id="toastwrap"></div>
<script type="module">
  import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';

  const css = getComputedStyle(document.documentElement);
  const tok = (name) => new THREE.Color(css.getPropertyValue(name).trim());
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;

  const canvas = document.getElementById('scene');
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  const scene = new THREE.Scene();
  scene.fog = new THREE.Fog(0, 8, 22);

  const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
  camera.position.set(0, 0.4, 9);

  const knot = new THREE.Mesh(
    new THREE.TorusKnotGeometry(2.1, 0.62, 220, 36),
    new THREE.MeshStandardMaterial({ metalness: 0.55, roughness: 0.32 })
  );
  knot.position.x = 2.2;
  scene.add(knot);

  const pts = new THREE.BufferGeometry();
  const N = 900, arr = new Float32Array(N * 3);
  for (let i = 0; i < N; i++) {
    const r = 5.4 + Math.random() * 7, t = Math.random() * Math.PI * 2, p = Math.acos(2 * Math.random() - 1);
    arr[i * 3] = r * Math.sin(p) * Math.cos(t); arr[i * 3 + 1] = r * Math.cos(p) * 0.6; arr[i * 3 + 2] = r * Math.sin(p) * Math.sin(t) - 4;
  }
  pts.setAttribute('position', new THREE.BufferAttribute(arr, 3));
  const dots = new THREE.Points(pts, new THREE.PointsMaterial({ size: 0.035, transparent: true, opacity: 0.7 }));
  scene.add(dots);

  const ambient = new THREE.AmbientLight(0, 0.5);
  scene.add(ambient);
  const key = new THREE.DirectionalLight(0, 1.1);
  key.position.set(-4, 6, 6);
  scene.add(key);
  const rim = new THREE.PointLight(0, 260, 40);
  rim.position.set(6.5, -1.5, 3.5);
  scene.add(rim);

  /* The scene takes its colours from the tokens, and takes them again whenever the theme or brand
     changes (the studio flips a frame after load) or a theme sheet lands. In light the glass is
     lit from the surface tones, so the knot stays light behind dark copy; in dark it stays dark. */
  function retheme() {
    const light = document.documentElement.dataset.theme === 'light';
    scene.background = tok('--bg');
    scene.fog.color = tok('--bg');
    knot.material.color = tok('--surface-2');
    knot.material.metalness = light ? 0.15 : 0.55;
    dots.material.color = tok('--text-3');
    ambient.color = light ? tok('--surface') : tok('--text-3');
    ambient.intensity = light ? 1.6 : 0.5;
    key.color = light ? tok('--surface') : tok('--text-1');
    rim.color = tok('--accent');
    rim.intensity = light ? 90 : 260;
    renderer.render(scene, camera);
  }
  new MutationObserver(retheme).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-brand'] });
  document.addEventListener('load', (e) => { if (e.target instanceof HTMLLinkElement) retheme(); }, true);

  let dragX = 0, dragY = 0, tx = 0, ty = 0, dragging = false, px = 0, py = 0;
  canvas.addEventListener('pointerdown', (e) => { dragging = true; px = e.clientX; py = e.clientY; });
  addEventListener('pointerup', () => { dragging = false; });
  addEventListener('pointermove', (e) => {
    if (!dragging) return;
    tx += (e.clientX - px) * 0.005; ty += (e.clientY - py) * 0.004;
    ty = Math.max(-0.9, Math.min(0.9, ty));
    px = e.clientX; py = e.clientY;
  });

  function size() {
    const w = canvas.clientWidth, h = canvas.clientHeight;
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
  }
  addEventListener('resize', size);
  size();
  retheme();

  renderer.setAnimationLoop((t) => {
    if (!reduced) { knot.rotation.y = t * 0.00012; dots.rotation.y = t * 0.00003; }
    dragX += (tx - dragX) * 0.08; dragY += (ty - dragY) * 0.08;
    knot.rotation.x = dragY; knot.rotation.z = dragX * 0.6;
    renderer.render(scene, camera);
  });
</script>
<script>
  document.getElementById('cta-toast').addEventListener('click', () => {
    const wrap = document.getElementById('toastwrap');
    wrap.replaceChildren();
    const toast = document.createElement('div');
    toast.className = 'toast ok';
    toast.style.position = 'fixed';
    toast.style.right = 'var(--s-5)';
    toast.style.bottom = 'var(--s-5)';
    toast.style.zIndex = '9';
    toast.innerHTML = '<span class="dot"></span>Request received. The fictional team will be in touch.';
    wrap.append(toast);
    setTimeout(() => toast.remove(), 2600);
  });
</script>
</body>
</html>