Frontile

Spinner

Indicates a loading state with an animated visual cue.

Import

import { Spinner } from 'frontile';

Usage

import { Spinner } from 'frontile';

<template><Spinner /></template>

Sizes

@size controls the Spinner's size: xs, sm, md, lg, or xl.

import { Spinner } from 'frontile';

<template>
  <div class='flex items-center space-x-2'>
    <Spinner @size='xs' />
    <Spinner @size='sm' />
    <Spinner @size='md' />
    <Spinner @size='lg' />
    <Spinner @size='xl' />
  </div>
</template>

Colors

@color changes the Spinner's color to match common UI patterns, such as primary actions, success states, warnings, and danger actions.

import { Spinner } from 'frontile';

<template>
  <div class='flex items-center space-x-2'>
    <Spinner @color='neutral' />
    <Spinner @color='primary' />
    <Spinner @color='secondary' />
    <Spinner @color='tertiary' />
    <Spinner @color='success' />
    <Spinner @color='warning' />
    <Spinner @color='danger' />
  </div>
</template>

Style Customization

Add custom CSS classes with the class argument for anything the built-in options don't cover. Override colors with fill-{*} classes for the highlighted color and text-{*} classes for the background. Semantic color utilities adapt automatically across themes.

import { Spinner } from 'frontile';

<template>
  <div class='flex items-center justify-center'>
    <Spinner @class='h-24 w-24 fill-secondary text-primary-muted' />
  </div>
</template>

Accessibility

The spinner is aria-hidden by default: it is a picture of a state, not the state itself. An unhidden, unnamed <svg> is announced as an image with no name, which tells a screen reader user nothing — and naming the graphic ("Loading spinner") describes the decoration rather than saying that the content they asked for is on its way.

Put the state on the thing that is loading. aria-busy marks the region, and a polite live region announces the transition once:

No routes loaded yet.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Button, Spinner } from 'frontile';

export default class Example extends Component {
  @tracked isLoading = false;
  @tracked rows: string[] = [];

  load = async () => {
    this.isLoading = true;
    this.rows = [];
    await new Promise((resolve) => setTimeout(resolve, 1500));
    this.rows = ['Fiber route A', 'Fiber route B', 'Fiber route C'];
    this.isLoading = false;
  };

  <template>
    <div class='flex flex-col gap-3'>
      <Button @color='primary' @onPress={{this.load}}>Load routes</Button>

      <div
        aria-busy={{if this.isLoading 'true' 'false'}}
        aria-live='polite'
        class='border-neutral-soft rounded border p-4'
      >
        {{#if this.isLoading}}
          <span class='flex items-center gap-2'>
            <Spinner @size='sm' @color='primary' />
            Loading routes…
          </span>
        {{else if this.rows}}
          <ul class='not-prose'>
            {{#each this.rows as |row|}}
              <li>{{row}}</li>
            {{/each}}
          </ul>
        {{else}}
          <p class='text-neutral'>No routes loaded yet.</p>
        {{/if}}
      </div>
    </div>
  </template>
}

The visible "Loading routes…" text is what carries the meaning here — the spinner beside it is redundant by design, which is exactly what makes hiding it correct.

A spinner with no adjacent text needs the text supplied some other way, or the wait is silent. VisuallyHidden is the usual answer for a button that swaps its label for a spinner:

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Button, Spinner, VisuallyHidden } from 'frontile';

export default class Example extends Component {
  @tracked isSaving = false;

  save = async () => {
    this.isSaving = true;
    await new Promise((resolve) => setTimeout(resolve, 1500));
    this.isSaving = false;
  };

  <template>
    <Button @color='primary' @onPress={{this.save}} disabled={{this.isSaving}}>
      {{#if this.isSaving}}
        <Spinner @size='sm' />
        <VisuallyHidden>Saving, please wait</VisuallyHidden>
      {{else}}
        Save
      {{/if}}
    </Button>
  </template>
}

If you need the graphic itself announced — a full-page loader with nothing else on screen — pass your own attributes, which are applied after the default and so win:

import { Spinner } from 'frontile';

<template>
  <Spinner @size='xl' aria-hidden='false' role='status' aria-label='Loading' />
</template>

Motion is the other consideration: the theme applies animate-spin with no motion-reduce variant, so the spinner keeps turning for users who have asked for reduced motion. If your product treats that preference as absolute, suppress it yourself:

import { Spinner } from 'frontile';

<template>
  <Spinner @color='primary' @class='motion-reduce:animate-none' />
</template>

Which is another reason not to let a spinner be the only sign that something is happening: the text beside it keeps working when the animation does not.

API

Spinner

Element: SVGElement

Arguments

Name Type Default Description
class string - Custom class name, it will override the default ones using Tailwind Merge library. Use fill-* for the highlighted arc and text-* for the track.
color enum 'neutral' The color of the spinner.
intent
Deprecated
enum -

Deprecated. Use `color`. `default` is now `neutral`.

size enum 'md' The size of the spinner.
Released under MIT License - Created by Josemar Luedke