Helix Loader
BetaLoaders · Feedback
Two strands of dots turning on a slow sine. For the parts of a product that are laboratory rather than bedside.
4 states17 props16.3 KB installedsince 0.2.0
npx @oxygenui-design/cli add helix-loaderFirst install? Run oxygen init once to say where your @/ alias points. The source is copied into your repository, along with anything it depends on.
Preview
Every state, switchable.
States3
Why this state exists
Eighteen dots, two strands, one keyframe. Depth is faked with scale and opacity rather than a 3D transform — so the strands cross convincingly and render identically in every browser.
Why it exists
The most specific loader in the set, and deliberately so.
Sequencing, pathology, and diagnostics screens are waiting on analysis rather than on a person, and a cardiac mark says the wrong thing there — as does a generic ring, which says nothing at all. Depth is faked with scale and opacity rather than a 3D transform because a rotateY helix renders differently across browsers and costs a layer per dot; eighteen phase-offset dots on one keyframe read as a rotation and cost nothing.
Usage
Props are the FHIR resource.
import { HelixLoader } from "@/components/oxygen/helix-loader";
<HelixLoader mode="overlay" label="Running the panel" delay={200} />Dependencies
- clsx
- tailwind-merge
17 props
- actions
React.ReactNode
Rendered under the hint — a Retry or Go back control while someone waits.
- announce
LoaderAnnounce
Live-region politeness while indeterminate.
- delay
number
Wait this long before appearing, so a fast response never flashes a loader.
- hint
string
A second line under the label. Never a substitute for it.
- label
string
What is loading. Always rendered — visibly when `showLabel`, and to assistive technology either way, because a loader nobody can hear is a silent wait.
- minDuration
number
Once visible, stay at least this long, so the loader never blinks out.
- mode
LoaderMode
`inline` sits in the flow; `overlay` covers its positioned ancestor; `page` covers the viewport.
- motion
LoaderMotion
`auto` follows the OS; `reduced` forces the still state; `full` opts out of the OS preference.
- onSlow
(() => void)
Fires once, when `slowAfter` elapses.
- open
boolean
Controlled visibility. Setting false runs the exit and respects `minDuration`.
Page 1 of 2
Source
Exactly what lands in your repository.
Read directly from the published registry, so this can never drift from what the CLI installs.
Show sourceHide source83 lines
"use client";
/**
* HelixLoader — two strands of dots turning on a slow sine.
*
* For the parts of a healthcare product that are laboratory rather than
* bedside: genomics, pathology, diagnostics, research tooling. It says
* "analysis is running" the way the cardiac loaders say "a person is waiting".
*
* Depth is faked with scale and opacity rather than a 3D transform, so the two
* strands read as passing in front of and behind each other while staying
* compositor-cheap and rendering identically in every browser. The phase offset
* per column is what turns eighteen independent dots into one rotation.
*
* The most specific of the five, and deliberately so: a scheduling product
* should not reach for this, and a sequencing product should not settle for a
* generic ring.
*/
import * as React from "react";
import {
LOADER_VIEWBOX,
LoaderFrame,
clamp,
cycleMs,
resolveLoaderSize,
type LoaderCommonProps,
type LoaderVars,
} from "@/lib/oxygen-loader";
/** Columns across the strand. Nine reads as a helix; fewer reads as dots. */
const COLUMNS = 9;
const DOTS = Array.from({ length: COLUMNS }, (_, index) => ({
x: 12 + index * 17,
/** Strand B trails strand A by half a turn — that is what crosses them. */
phaseA: -(index / COLUMNS),
phaseB: -(index / COLUMNS) - 0.5,
}));
export type HelixLoaderProps = LoaderCommonProps;
export function HelixLoader({ speed = 1, size, ...props }: HelixLoaderProps) {
const px = resolveLoaderSize(size, "xl");
return (
<LoaderFrame
{...props}
variant="helix"
vars={{
"--ox-loader-size": `${px}px`,
"--ox-loader-cycle": `${cycleMs(4000, clamp(speed, 0.5, 2))}ms`,
"--ox-loader-stroke": "1.5px",
}}
art={
<svg viewBox={LOADER_VIEWBOX.helix} focusable="false">
<line className="ox-loader__stroke ox-loader__track" x1="8" y1="30" x2="152" y2="30" />
{DOTS.map((dot) => (
<circle
key={`a-${dot.x}`}
className="ox-loader__fill ox-loader__dot"
style={{ "--ox-loader-phase": dot.phaseA } as LoaderVars}
cx={dot.x}
cy="30"
r="4.2"
/>
))}
{DOTS.map((dot) => (
<circle
key={`b-${dot.x}`}
className="ox-loader__fill ox-loader__dot"
style={{ "--ox-loader-phase": dot.phaseB } as LoaderVars}
cx={dot.x}
cy="30"
r="4.2"
/>
))}
</svg>
}
/>
);
}