Home/Components/Loaders/DNA Helix Loader - React Loader Spinner
Code · Live Preview
Componenttsx
// src/components/HelixLoader/HelixLoader.tsx
import { motion, useReducedMotion } from "framer-motion";
import { useId, useMemo } from "react";

type HelixLoaderProps = {
  className?: string;
  /** Width in px — height is always half the width */
  size?: number;
  /** Seconds for one full twist of the helix */
  speed?: number;
  /** Number of dot pairs along the helix */
  dots?: number;
  label?: string;
};

// viewBox is 200 x 100
const VIEW_W = 200;
const CENTER_Y = 50;
const PADDING_X = 16;
const AMPLITUDE = 30; // how far dots travel up and down
const DOT_RADIUS = 4.4;
const TWIST = 0.55; // phase shift between neighbouring columns (radians)
const SAMPLES = 24; // keyframes per cycle — higher = smoother curve

// Strand colors blend from the first to the second color, left to right
const STRAND_A: [string, string] = ["#00E5DE", "#3D4DFF"];
const STRAND_B: [string, string] = ["#FF3DF5", "#FF9F1A"];

function mixHex(from: string, to: string, t: number) {
  const a = parseInt(from.slice(1), 16);
  const b = parseInt(to.slice(1), 16);
  const channel = (shift: number) => {
    const x = (a >> shift) & 255;
    const y = (b >> shift) & 255;
    return Math.round(x + (y - x) * t);
  };
  const hex = (channel(16) << 16) | (channel(8) << 8) | channel(0);
  return `#${hex.toString(16).padStart(6, "0")}`;
}

const round = (n: number) => Math.round(n * 100) / 100;

type StrandFrames = { cy: number[]; r: number[]; opacity: number[] };

/**
 * One full turn of a dot around the helix axis.
 * sin() moves it up and down, cos() fakes depth:
 * dots at the front are bigger and brighter, dots at the back smaller and dimmer.
 */
function strandFrames(phase: number): StrandFrames {
  const cy: number[] = [];
  const r: number[] = [];
  const opacity: number[] = [];

  for (let k = 0; k <= SAMPLES; k++) {
    const theta = phase + (k / SAMPLES) * Math.PI * 2;
    const depth = (1 + Math.cos(theta)) / 2; // 0 = back, 1 = front

    cy.push(round(CENTER_Y + AMPLITUDE * Math.sin(theta)));
    r.push(round(DOT_RADIUS * (0.55 + 0.45 * depth)));
    opacity.push(round(0.3 + 0.7 * depth));
  }

  return { cy, r, opacity };
}

export function HelixLoader({
  className = "",
  size = 200,
  speed = 2.4,
  dots = 12,
  label = "Loading",
}: HelixLoaderProps) {
  const uid = useId().replace(/:/g, "");
  const reduce = useReducedMotion();

  const columns = useMemo(() => {
    const count = Math.max(2, Math.round(dots));
    const step = (VIEW_W - PADDING_X * 2) / (count - 1);

    return Array.from({ length: count }, (_, i) => {
      const t = i / (count - 1);
      const phase = i * TWIST;

      return {
        x: round(PADDING_X + i * step),
        a: strandFrames(phase),
        // The second strand sits half a turn behind the first
        b: strandFrames(phase + Math.PI),
        colorA: mixHex(STRAND_A[0], STRAND_A[1], t),
        colorB: mixHex(STRAND_B[0], STRAND_B[1], t),
      };
    });
  }, [dots]);

  const loop = { duration: speed, ease: "linear" as const, repeat: Infinity };

  return (
    <div
      className={`flex flex-col items-center gap-4 ${className}`}
      role="status"
      aria-label={label || "Loading"}
    >
      <div
        className="relative aspect-[2/1]"
        style={{ width: size, maxWidth: "100%" }}
      >
        <svg
          viewBox="0 0 200 100"
          className="h-full w-full overflow-visible"
          fill="none"
          aria-hidden="true"
        >
          <defs>
            <filter
              id={`helix-glow-${uid}`}
              x="-50%"
              y="-50%"
              width="200%"
              height="200%"
            >
              <feGaussianBlur stdDeviation="2.2" result="blur" />
              <feMerge>
                <feMergeNode in="blur" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>
          </defs>

          {/* Rungs — thin lines joining each pair of dots */}
          {columns.map((col, i) =>
            reduce ? (
              <line
                key={`rung-${i}`}
                x1={col.x}
                x2={col.x}
                y1={col.a.cy[0]}
                y2={col.b.cy[0]}
                stroke="currentColor"
                strokeOpacity={0.16}
                strokeWidth={1.5}
                strokeLinecap="round"
              />
            ) : (
              <motion.line
                key={`rung-${i}`}
                x1={col.x}
                x2={col.x}
                stroke="currentColor"
                strokeOpacity={0.16}
                strokeWidth={1.5}
                strokeLinecap="round"
                initial={{ y1: col.a.cy[0], y2: col.b.cy[0] }}
                animate={{ y1: col.a.cy, y2: col.b.cy }}
                transition={loop}
              />
            ),
          )}

          {/* Dots — two strands */}
          <g filter={`url(#helix-glow-${uid})`}>
            {columns.map((col, i) =>
              (
                [
                  [col.a, col.colorA],
                  [col.b, col.colorB],
                ] as const
              ).map(([frames, color], s) =>
                reduce ? (
                  <circle
                    key={`dot-${i}-${s}`}
                    cx={col.x}
                    cy={frames.cy[0]}
                    r={frames.r[0]}
                    opacity={frames.opacity[0]}
                    fill={color}
                  />
                ) : (
                  <motion.circle
                    key={`dot-${i}-${s}`}
                    cx={col.x}
                    fill={color}
                    initial={{
                      cy: frames.cy[0],
                      r: frames.r[0],
                      opacity: frames.opacity[0],
                    }}
                    animate={{
                      cy: frames.cy,
                      r: frames.r,
                      opacity: frames.opacity,
                    }}
                    transition={loop}
                  />
                ),
              ),
            )}
          </g>
        </svg>
      </div>

      {label && (
        <motion.span
          aria-hidden="true"
          className="text-sm font-medium tracking-[0.2em] text-neutral-400 uppercase"
          animate={reduce ? {} : { opacity: [0.4, 1, 0.4] }}
          transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
        >
          {label}
        </motion.span>
      )}
    </div>
  );
}
// Component Source: https://codekk.dev
DesktopSandpackTailwind v4

Component details

Overview, install command, dependencies, and project links.

A DNA helix loader for React built with TypeScript, Framer Motion, SVG, and Tailwind CSS. Two strands of glowing dots twist around each other, joined by thin rungs, like a rotating strand of DNA. The 3D effect needs no 3D library or canvas. Each dot moves up and down on a sine wave, and a matching cosine controls its depth. Dots swinging toward the viewer grow to full size and brightness, while dots at the back shrink to about half size and fade, so the flat SVG reads as a helix turning in space. Each column starts slightly further along its turn than its neighbor, which creates the twist, and the second strand runs half a turn behind the first so the strands always cross. All motion is precomputed into smooth keyframe loops with useMemo, and each dot animates its position, radius, and opacity through Framer Motion on native SVG circles. The rungs stretch and shrink as the dots move, and they use currentColor at low opacity so they adapt to light or dark themes. The top strand blends cyan to blue from left to right and the other blends magenta to orange, with every dot's color calculated from its position. An SVG blur filter adds a soft neon glow. The number of dot pairs is adjustable with the dots prop, and spacing recalculates automatically. Filter IDs come from React's useId, so multiple loaders can share a page without conflicts. The wrapper uses role="status" with an accessible label, and the visuals are hidden from screen readers. When prefers-reduced-motion is enabled, the helix freezes in a twisted pose instead of disappearing. It works in Vite, Create React App, and Next.js.

Added Sep 27, 2026

Discussion

Feedback, implementation tips, and usage notes.

0 threads

Comments

Sign in to join the conversation