Spinner
v0.4.0A loading spinner with size variants and accessible labeling.
registry/behivetech/atoms/spinner · depends on @behivetech/get-class-name
spinner.tsx
1import { type HTMLAttributes } from "react";2import { getClassName } from "@behivetech/get-class-name";3import styles from "./spinner.module.scss";45export type SpinnerSize = "sm" | "md" | "lg";67export interface SpinnerProps extends HTMLAttributes<HTMLSpanElement> {8 /** Controls the size; defaults to md */9 size?: SpinnerSize;10 /** Accessible label announced to screen readers while loading */11 label?: string;12 /** Additional class names to merge with the component root element */13 className?: string;14}1516export const Spinner = ({17 size = "md",18 label = "Loading...",19 className,20 ...rest21}: SpinnerProps) => {22 const [rootClass, getChildClass] = getClassName({23 className,24 rootClass: "BHT__Spinner",25 modifiers: { [size]: true },26 styles,27 });2829 return (30 <span {...rest} className={rootClass} role="status" aria-label={label}>31 <span className={getChildClass("circle")} aria-hidden="true" />32 </span>33 );34};
spinner.module.scss
1// Material Design 3 circular progress indicator (indeterminate): a `primary`2// active indicator turning over a `secondary-container` track.34.BHT__Spinner {5 display: inline-flex;6 align-items: center;7 justify-content: center;89 &__circle {10 display: block;11 border-style: solid;12 border-color: var(13 --md-sys-color-secondary-container,14 var(--color-border-strong)15 );16 border-top-color: var(--md-sys-color-primary, var(--color-primary));17 border-radius: var(--md-sys-shape-corner-full, 9999px);18 animation: spinner-rotate 0.7s linear infinite;19 }2021 &--sm {22 .BHT__Spinner {23 &__circle {24 width: 1rem;25 height: 1rem;26 border-width: 2px;27 }28 }29 }3031 &--md {32 .BHT__Spinner {33 &__circle {34 width: 1.5rem;35 height: 1.5rem;36 border-width: 2px;37 }38 }39 }4041 &--lg {42 .BHT__Spinner {43 &__circle {44 width: 2.5rem;45 height: 2.5rem;46 border-width: 3px;47 }48 }49 }50}5152@keyframes spinner-rotate {53 to {54 transform: rotate(360deg);55 }56}
index.ts
1export { Spinner } from "./spinner.js";2export type { SpinnerProps, SpinnerSize } from "./spinner.js";
spinner.composition.tsx
1import { Spinner } from './spinner.js';23export const SmallSpinner = () => {4 return <Spinner size="sm" />;5};67export const MediumSpinner = () => {8 return <Spinner size="md" />;9};1011export const LargeSpinner = () => {12 return <Spinner size="lg" />;13};1415export const SpinnerWithCustomLabel = () => {16 return <Spinner size="md" label="Saving changes" />;17};
spinner.spec.tsx
1import React from "react";2import { render, screen } from "@testing-library/react";3import { Spinner } from "./spinner";45describe("Spinner", () => {6 it("renders with default aria label", () => {7 render(<Spinner />);8 expect(screen.getByRole("status")).toBeTruthy();9 expect(screen.getByLabelText("Loading...")).toBeTruthy();10 });1112 it("renders with custom aria label", () => {13 render(<Spinner label="Saving changes" />);14 expect(screen.getByLabelText("Saving changes")).toBeTruthy();15 });1617 it("applies size modifier class", () => {18 const { container } = render(<Spinner size="lg" />);19 expect(container.firstChild?.className).toMatch(/Spinner--lg/);20 });21});