Breath Loader
BetaLoaders · Feedback
Three rings expanding and fading from a soft core, paced at a resting breath rather than a spinner's tempo.
5 states18 props16.3 KB installedsince 0.2.0
npx @oxygenui-design/cli add breath-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.
States4
Why this state exists
Three rings on a four-second cycle, a third apart, so the field never empties. Roughly fifteen a minute — the rate of calm breathing rather than a spinner's tempo.
Why it exists
A spinner's tempo tells a reader the system is working hard; breathing tells them they can wait.
On a patient-facing screen — a results page, a portal sign-in, a check-in kiosk — the second is almost always what the product means to say. It is also the one loader in the set with no clinical symbol at all, which is what makes it safe across specialties: nothing here reads as cardiac, oncological, or obstetric to someone who is about to receive news. That neutrality is a feature, not an absence of one.
Usage
Props are the FHIR resource.
import { BreathLoader } from "@/components/oxygen/breath-loader";
// Patient-facing page wait
<BreathLoader mode="page" label="Loading your information" />
// Slower still, for a long wait
<BreathLoader speed={0.7} label="Preparing your summary" hint="This can take a few seconds." />Dependencies
- clsx
- tailwind-merge
18 props
- actions
React.ReactNode
Rendered under the hint — a Retry or Go back control while someone waits.
- announce
LoaderAnnounce
Live-region politeness while indeterminate.
- children
React.ReactNode
A brand mark rendered in place of the core. Decorative: it sits inside the aria-hidden art, so a logo never becomes a second announcement on a wait that already has a label.
- 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.
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 source68 lines
"use client";
/**
* BreathLoader — three rings expanding and fading from a soft core.
*
* Paced at roughly fifteen cycles a minute, which is a resting respiratory
* rate. That is the whole idea: a spinner's tempo says "the system is working
* hard", and breathing says "you can wait". On a patient-facing screen — a
* results page, a portal login, a check-in kiosk — the second one is almost
* always what the product means.
*
* It carries no clinical symbol at all, which makes it the safe default across
* specialties: nothing here reads as cardiac, oncological, or obstetric to
* someone who is about to receive news. The core is also a slot: pass
* `children` and a customer's logo mark sits inside the rings.
*/
import * as React from "react";
import {
LOADER_VIEWBOX,
LoaderFrame,
clamp,
cycleMs,
resolveLoaderSize,
strokePx,
type LoaderCommonProps,
} from "@/lib/oxygen-loader";
export interface BreathLoaderProps extends LoaderCommonProps {
/**
* A brand mark rendered in place of the core.
*
* Decorative: it sits inside the aria-hidden art, so a logo never becomes a
* second announcement on a wait that already has a label.
*/
children?: React.ReactNode;
}
export function BreathLoader({ speed = 1, size, children, ...props }: BreathLoaderProps) {
const px = resolveLoaderSize(size, "lg");
return (
<LoaderFrame
{...props}
variant="breath"
vars={{
"--ox-loader-size": `${px}px`,
"--ox-loader-cycle": `${cycleMs(4000, clamp(speed, 0.5, 2))}ms`,
"--ox-loader-stroke": `${strokePx(px)}px`,
}}
art={
<svg viewBox={LOADER_VIEWBOX.breath} focusable="false">
{/* Three rings on one cycle, a third apart, so the field never empties. */}
<circle className="ox-loader__stroke ox-loader__ring" cx="60" cy="60" r="54" />
<circle className="ox-loader__stroke ox-loader__ring" cx="60" cy="60" r="54" />
<circle className="ox-loader__stroke ox-loader__ring" cx="60" cy="60" r="54" />
{/* The core is the slot: a supplied mark replaces it rather than
sitting behind it. */}
{children ? null : (
<circle className="ox-loader__fill ox-loader__core" cx="60" cy="60" r="11" />
)}
</svg>
}
mark={children}
/>
);
}