Loaders
DNAFlow
Two strands twist round each other as a double helix, with rungs stretching between them.
Download
- React
- Tailwind CSS v4
- motion
components/ui/dna-flow.tsx
"use client";
import { motion, useReducedMotion } from "motion/react";
type DNAFlowProps = {
className?: string;
color?: string;
/** Colour of the second strand. */
accent?: string;
/** Loader width in pixels. Height is half of it. */
size?: number;
/** Number of base pairs across. */
pairs?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Read out by screen readers. */
label?: string;
};
const STEPS = 16;
// One full turn sampled at STEPS points (plus the closing point).
const ANGLES = Array.from({ length: STEPS + 1 }, (_, k) => (k / STEPS) * Math.PI * 2);
/* DNAFlow: two strands twist round each other as a double helix; rungs stretch between them. */
export function DNAFlow({
className = "",
color = "#a78bfa",
accent = "#22d3ee",
size = 96,
pairs = 8,
speed = 1,
label = "Loading",
}: DNAFlowProps) {
const reduce = useReducedMotion();
const duration = 2.4 / speed;
const height = size / 2;
const amp = height * 0.38;
const dot = size * 0.07;
// Strand point at angle t: y from cos, depth (size + brightness) from sin.
const strand = (offset: number) => ({
y: ANGLES.map((t) => amp * Math.cos(t + offset)),
scale: ANGLES.map((t) => 0.7 + 0.3 * Math.sin(t + offset)),
opacity: ANGLES.map((t) => 0.45 + 0.55 * ((Math.sin(t + offset) + 1) / 2)),
});
const strandA = strand(0);
const strandB = strand(Math.PI);
const rung = { scaleY: ANGLES.map((t) => Math.abs(Math.cos(t))) };
return (
<div
role="status"
className={`relative inline-flex items-center justify-between ${className}`}
style={{ width: size, height }}
>
<span className="sr-only">{label}</span>
{Array.from({ length: pairs }, (_, i) => {
const phase = (i / pairs) * Math.PI * 2;
const transition = { duration, repeat: Infinity, ease: "linear" as const, delay: -(i / pairs) * duration };
// Reduced motion: hold the helix still at this column's phase.
const still = (offset: number) => ({
y: amp * Math.cos(phase + offset),
scale: 0.7 + 0.3 * Math.sin(phase + offset),
});
return (
<span key={i} aria-hidden className="relative flex h-full items-center justify-center" style={{ width: dot }}>
<motion.span
className="absolute w-[1.5px] rounded-full"
style={{ height: amp * 2, background: `linear-gradient(${color}, ${accent})`, opacity: 0.35 }}
animate={reduce ? { scaleY: Math.abs(Math.cos(phase)) } : rung}
transition={reduce ? { duration: 0 } : transition}
/>
{[
{ c: color, frames: strandA, offset: 0 },
{ c: accent, frames: strandB, offset: Math.PI },
].map(({ c, frames, offset }) => (
<motion.span
key={offset}
className="absolute rounded-full"
style={{ width: dot, height: dot, background: c, boxShadow: `0 0 ${dot}px ${c}` }}
animate={reduce ? { ...still(offset), opacity: [0.5, 1, 0.5] } : frames}
transition={reduce ? { duration: 3, repeat: Infinity, ease: "easeInOut" } : transition}
/>
))}
</span>
);
})}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/dna-flow.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/dna-flow-demo.tsx
import { DNAFlow } from "@/components/ui/dna-flow"; export default function DNAFlowExample() { return ( <div className="grid h-full place-items-center bg-[radial-gradient(circle_at_50%_50%,#1d1a33,#0d0c14_70%)]"> <DNAFlow size={128} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| color | string | "#a78bfa" | First strand colour. |
| accent | string | "#22d3ee" | Second strand colour. |
| size | number | 96 | Width in pixels. Height is half of it. |
| pairs | number | 8 | Number of base pairs across. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "Loading" | Read out by screen readers (role=status). |
| className | string | "" | Extra classes on the wrapper. |