Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Spinner

An indeterminate loading indicator.

An indeterminate activity indicator. iOS renders the classic eight fading spokes; Android renders an MD3 arc that sweeps while it rotates. Color is inherited via currentColor.

Installation

npx touchcn add spinner

Usage

<tcn-spinner size="md" />
<tcn-spinner class="text-[var(--color-destructive)]" />
import { TcnSpinner } from '@/components/ui/spinner';

<TcnSpinner size="md" />
<TcnSpinner className="text-[var(--color-destructive)]" />
<script setup lang="ts">
import { TcnSpinner } from '@/components/ui/spinner';
</script>

<template>
  <TcnSpinner size="md" />
  <TcnSpinner class="text-[var(--color-destructive)]" />
</template>

The spinner defaults to --color-primary; override it with any text-color utility since both platforms draw with currentColor. Animation is disabled and slowed under prefers-reduced-motion.

Props

Prop Type Default Description
size 'sm' | 'md' | 'lg' 'md' 18 / 28 / 40 px indicator.
label string 'Loading' Accessible label (role="status").
Source · Angular
export * from './tcn-spinner';
import { Component, computed, ElementRef, inject, input } from '@angular/core';
import { cn } from '@touchcn/core';

export type TcnSpinnerSize = 'sm' | 'md' | 'lg';

const sizeClass: Record<TcnSpinnerSize, string> = {
  sm: 'size-[18px]',
  md: 'size-7',
  lg: 'size-10',
};

/**
 * Indeterminate activity indicator. The structure forks by platform: iOS renders
 * eight fading spokes that tick round (UIActivityIndicator), MD renders a single
 * arc that sweeps while rotating. Colour rides on `currentColor` (defaults to
 * `--color-primary`); `role="status"` + `aria-label` announce it. All animation
 * lives in `theme.css`, including the reduced-motion fallback.
 */
@Component({
  selector: 'tcn-spinner',
  template: `
    <span class="tcn-spinner-ios if-ios" aria-hidden="true">
      @for (spoke of spokes; track $index) {
        <span class="tcn-spinner-spoke"></span>
      }
    </span>
    <svg class="tcn-spinner-md if-md" viewBox="0 0 48 48" aria-hidden="true">
      <circle class="tcn-spinner-arc" cx="24" cy="24" r="20" />
    </svg>
  `,
  host: {
    role: 'status',
    '[attr.aria-label]': 'label()',
    '[class]': 'classes()',
  },
})
export class TcnSpinner {
  private readonly authored = inject(ElementRef<HTMLElement>).nativeElement.getAttribute('class') ?? '';

  readonly size = input<TcnSpinnerSize>('md');
  readonly label = input('Loading');

  protected readonly spokes = Array.from({ length: 8 });

  protected readonly classes = computed(() => cn('tcn-spinner', sizeClass[this.size()], this.authored));
}
Source · React
export * from './tcn-spinner';
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
import { cn } from '@touchcn/core';

export type TcnSpinnerSize = 'sm' | 'md' | 'lg';

const sizeClass: Record<TcnSpinnerSize, string> = {
  sm: 'size-[18px]',
  md: 'size-7',
  lg: 'size-10',
};

export interface TcnSpinnerProps extends ComponentPropsWithoutRef<'span'> {
  size?: TcnSpinnerSize;
  /** Accessible label announced by the `status` role. */
  label?: string;
}

/**
 * Indeterminate activity indicator. The structure forks by platform: iOS renders
 * eight fading spokes that tick round (UIActivityIndicator), MD renders a single
 * arc that sweeps while rotating. Colour rides on `currentColor` (defaults to
 * `--color-primary`); `role="status"` + `aria-label` announce it. All animation
 * lives in `theme.css`, including the reduced-motion fallback.
 */
export const TcnSpinner = forwardRef<HTMLSpanElement, TcnSpinnerProps>(function TcnSpinner(
  { size = 'md', label = 'Loading', className, ...props },
  ref,
) {
  return (
    <span ref={ref} role="status" aria-label={label} className={cn('tcn-spinner', sizeClass[size], className)} {...props}>
      <span className="tcn-spinner-ios if-ios" aria-hidden="true">
        {Array.from({ length: 8 }).map((_, index) => (
          <span key={index} className="tcn-spinner-spoke" />
        ))}
      </span>
      <svg className="tcn-spinner-md if-md" viewBox="0 0 48 48" aria-hidden="true">
        <circle className="tcn-spinner-arc" cx="24" cy="24" r="20" />
      </svg>
    </span>
  );
});
Source · Vue
<script lang="ts">
export type TcnSpinnerSize = 'sm' | 'md' | 'lg';

const sizeClass: Record<TcnSpinnerSize, string> = {
  sm: 'size-[18px]',
  md: 'size-7',
  lg: 'size-10',
};
</script>

<script setup lang="ts">
import { computed } from 'vue';
import { cn } from '@touchcn/core';

const props = withDefaults(
  defineProps<{
    size?: TcnSpinnerSize;
    /** Accessible label announced by the `status` role. */
    label?: string;
  }>(),
  { size: 'md', label: 'Loading' },
);

/**
 * Indeterminate activity indicator. The structure forks by platform: iOS renders
 * eight fading spokes that tick round (UIActivityIndicator), MD renders a single
 * arc that sweeps while rotating. Colour rides on `currentColor` (defaults to
 * `--color-primary`); `role="status"` + `aria-label` announce it. All animation
 * lives in `theme.css`, including the reduced-motion fallback.
 */
const classes = computed(() => cn('tcn-spinner', sizeClass[props.size]));
</script>

<template>
  <span role="status" :aria-label="label" :class="classes">
    <span class="tcn-spinner-ios if-ios" aria-hidden="true">
      <span v-for="index in 8" :key="index" class="tcn-spinner-spoke" />
    </span>
    <svg class="tcn-spinner-md if-md" viewBox="0 0 48 48" aria-hidden="true">
      <circle class="tcn-spinner-arc" cx="24" cy="24" r="20" />
    </svg>
  </span>
</template>
export { default as TcnSpinner } from './TcnSpinner.vue';
export type { TcnSpinnerSize } from './TcnSpinner.vue';

Last updated on July 24, 2026

Was this page helpful?