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();
})();