import { Loader2Icon } from 'lucide-react'import { cn } from '@/lib/utils'/** * An animated loading indicator, announced as a status named "Loading". * * Pass an `aria-label` to translate or specify the name: this package carries no translations, so "Loading" is only * an English fallback. Pass `aria-hidden` when visible text next to the spinner already says what is happening; the * spinner is then decorative and has no role or name. */function Spinner({ className, role = 'status', 'aria-hidden': ariaHidden, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props}: React.ComponentProps<'svg'>) { const hidden = ariaHidden === true || ariaHidden === 'true' return ( <Loader2Icon data-slot="spinner" className={cn('size-4 animate-spin', className)} {...(hidden ? { 'aria-hidden': true } : { role, 'aria-label': ariaLabel ?? (ariaLabelledBy ? undefined : 'Loading'), 'aria-labelledby': ariaLabelledBy, })} {...props} /> )}export { Spinner }
An indicator that shows a loading state. It renders an animated icon with role="status" and the accessible name "Loading", and can be sized and coloured with utility classes or placed inside buttons, badges and other components.
Features
Accessible by default with role="status" and aria-label="Loading"
aria-label replaces the English default with a translation or a more specific description
aria-hidden makes the spinner decorative, with no role or name, next to text that describes the state
Size and colour via className
Works inside buttons, badges, inputs and attachments
Usage
import { Button } from '@/components/ui/button'import { Spinner } from '@/components/ui/spinner'export function SubmittingButton() { return ( <Button disabled> <Spinner aria-hidden /> Submitting application </Button> )}
Naming the spinner
rapidUI carries no translations, so "Loading" is only an English fallback. Pass the name from your app's translation
layer with aria-label, or point at visible text with aria-labelledby:
// On its own: give it a translated or more specific name<Spinner aria-label={t('cases.loading')} />// Next to visible text that says the same thing: hide it from screen readers<Button disabled> <Spinner aria-hidden /> {t('application.sending')}</Button>
With aria-hidden the spinner renders without role="status" and without a label, so screen readers skip it and read
only the text.
Examples
Default
A background job shown as an Item, with a specific aria-label on the spinner.
Inside disabled buttons during submission, at the start or end with data-icon. The button text says what is happening, so the spinners are aria-hidden.
As an inline-end addon while an address is looked up in the property register.
import { SearchIcon } from 'lucide-react'import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'import { Label } from '@/components/ui/label'import { Spinner } from '@/components/ui/spinner'export function SpinnerInInputGroup() { return ( <div className="flex w-full max-w-sm flex-col gap-2"> <Label htmlFor="spinner-address">Property address</Label> <InputGroup> <InputGroupAddon> <SearchIcon /> </InputGroupAddon> <InputGroupInput id="spinner-address" defaultValue="Storgatan 14" /> <InputGroupAddon align="inline-end"> <Spinner aria-label="Searching the property register" /> </InputGroupAddon> </InputGroup> <p className="text-sm text-muted-foreground">Looking up the property register…</p> </div> )}
Empty State
Inside an Empty while records are retrieved from the archive, with a way to cancel.
import { Button } from '@/components/ui/button'import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle,} from '@/components/ui/empty'import { Spinner } from '@/components/ui/spinner'export function SpinnerEmptyState() { return ( <Empty className="w-full max-w-md border"> <EmptyHeader> <EmptyMedia variant="icon"> <Spinner /> </EmptyMedia> <EmptyTitle>Retrieving records from the archive</EmptyTitle> <EmptyDescription> Records requested before 2010 are stored in the central archive. This can take up to a minute. </EmptyDescription> </EmptyHeader> <EmptyContent> <Button variant="outline" size="sm"> Cancel request </Button> </EmptyContent> </Empty> )}
Loading State
Per-file progress in an attachment list for a building permit application.
Has role="status" and aria-label="Loading" by default
Pass aria-label to translate the name or make it more specific, such as "Generating decision letter". aria-labelledby works too and replaces the default label
Pass aria-hidden when visible text next to the spinner describes the state, as in a button or badge. The spinner then has no role and no label, so the state isn't announced twice
Disable buttons while an action is in progress to prevent duplicate submissions
Respect reduced motion preferences where the animation is not essential