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';