Back to CSS 3D Chromatic Helix CSS + JS
Share
HTML
<section class="cd-chx" aria-label="Chromatic Helix 3D DNA helix demo">
  <div class="card">
    <div class="cd-chx-nebula" aria-hidden="true"></div>
    <canvas class="cd-chx-stars" data-cd-chx-stars aria-hidden="true"></canvas>

    <div class="scene" data-cd-chx-scene>
      <div class="axis" aria-hidden="true"></div>
    </div>

    <div class="data-readout" aria-hidden="true">
      <div class="data-item"><span class="data-val">60</span>NODES</div>
      <div class="data-item"><span class="data-val">2</span>STRANDS</div>
      <div class="data-item"><span class="data-val">4</span>TURNS</div>
    </div>
  </div>
</section>
CSS
/* ─── 03 Chromatic Helix — 3D DNA double helix ─────────────── */
.cd-chx {
  --cd-chx-bg: #010209;

  position: relative;
  width: 100%;
  height: 620px;
  background: var(--cd-chx-bg);
  overflow: hidden;
  perspective: 900px;
  box-sizing: border-box;
}

.cd-chx *,
.cd-chx *::before,
.cd-chx *::after { box-sizing: border-box; margin: 0; padding: 0; }

.cd-chx .card {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.cd-chx .cd-chx-nebula {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(ellipse 60% 40% at 25% 30%, rgba(60, 10, 120, 0.16) 0%, transparent 70%),
    radial-gradient(ellipse 50% 35% at 75% 70%, rgba(10, 60, 120, 0.12) 0%, transparent 70%),
    radial-gradient(ellipse 40% 30% at 50% 50%, rgba(90, 15, 60, 0.08) 0%, transparent 70%);
}

.cd-chx .cd-chx-stars {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* Scene */
.cd-chx .scene {
  position: relative;
  width: 360px;
  height: 520px;
  transform-style: preserve-3d;
  animation: cd-chx-scene-rotate 18s linear infinite;
  cursor: grab;
}
.cd-chx .scene.dragging { cursor: grabbing; }

@keyframes cd-chx-scene-rotate {
  from { transform: rotateY(0deg) rotateX(8deg); }
  to   { transform: rotateY(360deg) rotateX(8deg); }
}

.cd-chx .orb {
  position: absolute;
  border-radius: 50%;
  transform-style: preserve-3d;
  will-change: transform;
}

.cd-chx .rung {
  position: absolute;
  height: 1px;
  transform-origin: left center;
  pointer-events: none;
}

.cd-chx .axis {
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  width: 1px;
  background: linear-gradient(to bottom,
    transparent 0%,
    rgba(180, 100, 255, 0.15) 15%,
    rgba(180, 100, 255, 0.25) 50%,
    rgba(180, 100, 255, 0.15) 85%,
    transparent 100%
  );
  transform: translateX(-50%) translateZ(0px);
  pointer-events: none;
}

/* Data readout */
.cd-chx .data-readout {
  position: absolute;
  bottom: 28px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 32px;
  pointer-events: none;
  z-index: 10;
}
.cd-chx .data-item {
  text-align: center;
  font-family: 'Courier New', ui-monospace, monospace;
  font-size: 10px;
  letter-spacing: 2px;
  color: rgba(180, 140, 255, 0.45);
}
.cd-chx .data-val {
  display: block;
  font-size: 16px;
  font-weight: bold;
  color: rgba(200, 160, 255, 0.7);
  margin-bottom: 2px;
}

@media (max-width: 720px) {
  .cd-chx { height: 560px; }
  .cd-chx .scene { width: 280px; height: 460px; }
}

@media (prefers-reduced-motion: reduce) {
  .cd-chx .scene { animation: none !important; }
}
JS
(() => {
  const root = document.querySelector('.cd-chx');
  if (!root) return;
  const scene = root.querySelector('[data-cd-chx-scene]');
  const canvas = root.querySelector('[data-cd-chx-stars]');
  if (!scene || !canvas) return;

  const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // Stars — sized to the wrapper, not the viewport
  function resizeCanvas() {
    const rect = root.getBoundingClientRect();
    canvas.width = rect.width;
    canvas.height = rect.height;
  }
  resizeCanvas();
  const resizeObs = new ResizeObserver(resizeCanvas);
  resizeObs.observe(root);

  const ctx = canvas.getContext('2d');
  const stars = Array.from({ length: 160 }, () => ({
    x: Math.random() * canvas.width,
    y: Math.random() * canvas.height,
    r: Math.random() * 1.2 + 0.2,
    a: Math.random() * 0.7 + 0.1,
    twinkle: Math.random() * Math.PI * 2,
    speed: Math.random() * 0.02 + 0.005,
  }));

  function drawStars(t) {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    stars.forEach(s => {
      const alpha = s.a * (0.6 + 0.4 * Math.sin(t * s.speed + s.twinkle));
      ctx.beginPath();
      ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
      ctx.fillStyle = `rgba(220, 210, 255, ${alpha})`;
      ctx.fill();
    });
    if (!prefersReduced) requestAnimationFrame(drawStars);
  }
  drawStars(0);

  // Helix
  const N = 30;
  const TURNS = 4;
  const RADIUS = 110;
  const HEIGHT = 480;
  const ORB_SIZE = 16;

  const palette1 = [
    [255, 100, 60], [255, 150, 40], [255, 200, 30], [220, 240, 50], [140, 240, 100],
  ];
  const palette2 = [
    [60, 220, 255], [80, 160, 255], [120, 90, 255], [200, 70, 255], [255, 80, 200],
  ];

  function lerpColor(p, t) {
    const seg = (p.length - 1) * t;
    const i = Math.min(Math.floor(seg), p.length - 2);
    const f = seg - i;
    return p[i].map((v, k) => Math.round(v + (p[i + 1][k] - v) * f));
  }

  function makeOrb(x, y, z, color, size, glowIntensity) {
    const [r, g, b] = color;
    const orb = document.createElement('div');
    orb.className = 'orb';
    const s = size || ORB_SIZE;
    orb.style.cssText = `
      width: ${s}px;
      height: ${s}px;
      left: calc(50% + ${x}px - ${s / 2}px);
      top: calc(50% + ${y}px - ${s / 2}px);
      transform: translateZ(${z}px);
      background: radial-gradient(circle at 35% 35%,
        rgba(${r},${g},${b}, 0.95) 0%,
        rgba(${r},${g},${b}, 0.75) 35%,
        rgba(${Math.max(0, r - 60)},${Math.max(0, g - 60)},${Math.max(0, b - 60)}, 0.5) 70%,
        transparent 100%
      );
      box-shadow:
        0 0 ${glowIntensity * 0.6}px rgba(${r},${g},${b},0.9),
        0 0 ${glowIntensity * 1.2}px rgba(${r},${g},${b},0.55),
        0 0 ${glowIntensity * 2}px rgba(${r},${g},${b},0.25),
        inset 0 0 ${s * 0.4}px rgba(255,255,255,0.25);
    `;
    return orb;
  }

  function makeRung(x1, y1, z1, x2, y2, z2, color1, color2) {
    const [r1, g1, b1] = color1;
    const [r2, g2, b2] = color2;
    const dx = x2 - x1;
    const dz = z2 - z1;
    const length = Math.sqrt(dx * dx + dz * dz);
    const angleY = Math.atan2(dx, dz) * (180 / Math.PI);
    const rung = document.createElement('div');
    rung.className = 'rung';
    rung.style.cssText = `
      width: ${length}px;
      left: calc(50% + ${x1}px);
      top: calc(50% + ${y1}px);
      transform: translateZ(${z1}px) rotateY(${angleY}deg);
      transform-origin: 0 0;
      background: linear-gradient(90deg,
        rgba(${r1},${g1},${b1},0.55),
        rgba(${Math.round((r1 + r2) / 2)},${Math.round((g1 + g2) / 2)},${Math.round((b1 + b2) / 2)},0.25),
        rgba(${r2},${g2},${b2},0.55)
      );
    `;
    return rung;
  }

  const yOffset = -HEIGHT / 2;
  for (let i = 0; i < N; i++) {
    const t = i / (N - 1);
    const angle = t * TURNS * Math.PI * 2;
    const y = yOffset + t * HEIGHT;

    const x1 = Math.cos(angle) * RADIUS;
    const z1 = Math.sin(angle) * RADIUS;
    const c1 = lerpColor(palette1, t);

    const x2 = Math.cos(angle + Math.PI) * RADIUS;
    const z2 = Math.sin(angle + Math.PI) * RADIUS;
    const c2 = lerpColor(palette2, t);

    const sz1 = ORB_SIZE + Math.sin(t * Math.PI * 3) * 3;
    const sz2 = ORB_SIZE + Math.cos(t * Math.PI * 3) * 3;

    scene.appendChild(makeOrb(x1, y, z1, c1, sz1, 22));
    scene.appendChild(makeOrb(x2, y, z2, c2, sz2, 22));

    if (i % 2 === 0) {
      scene.appendChild(makeRung(x1, y, z1, x2, y, z2, c1, c2));
    }
  }

  // Drag-to-scrub rotation — scoped to wrapper
  if (prefersReduced) return;

  let userRY = 0, dragging = false, lastX = 0, autoAngle = 0;

  scene.addEventListener('mousedown', e => {
    dragging = true;
    lastX = e.clientX;
    scene.classList.add('dragging');
    e.preventDefault();
  });
  document.addEventListener('mouseup', () => {
    dragging = false;
    scene.classList.remove('dragging');
  });
  document.addEventListener('mousemove', e => {
    if (!dragging) return;
    userRY += (e.clientX - lastX) * 0.4;
    lastX = e.clientX;
  });

  function rotateTick() {
    autoAngle += 0.18;
    scene.style.transform = `rotateY(${autoAngle + userRY}deg) rotateX(8deg)`;
    scene.style.animation = 'none';
    requestAnimationFrame(rotateTick);
  }
  rotateTick();
})();