shadcn-remix
GitHub

Spinner

An indicator that can be used to show a loading state.

Processing payment...
$100.00
import { css } from 'remix/component'

import { Item, ItemContent, ItemMedia, ItemTitle } from '#app/ui/item.tsx'
import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerDemo() {
import { css } from 'remix/component'

import { Item, ItemContent, ItemMedia, ItemTitle } from '#app/ui/item.tsx'
import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerDemo() {
  return () => (
    <div
      mix={css({
        display: 'flex',
        width: '100%',
        maxWidth: '20rem',
        flexDirection: 'column',
        gap: '1rem',
        '--radius': '1rem',
      })}
    >
      <Item variant="muted">
        <ItemMedia>
          <Spinner />
        </ItemMedia>
        <ItemContent>
          <ItemTitle
            mix={css({
              overflow: 'hidden',
              display: '-webkit-box',
              WebkitBoxOrient: 'vertical',
              WebkitLineClamp: '1',
            })}
          >
            Processing payment...
          </ItemTitle>
        </ItemContent>
        <ItemContent mix={css({ flex: 'none', justifyContent: 'flex-end' })}>
          <span
            mix={css({
              fontSize: '0.875rem',
              lineHeight: 'calc(1.25 / 0.875)',
              fontVariantNumeric: 'tabular-nums',
            })}
          >
            $100.00
          </span>
        </ItemContent>
      </Item>
    </div>
  )
}

Installation

$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/spinner.json

Usage

import { Spinner } from '#app/ui/spinner.tsx'
<Spinner />

Examples

Customization

You can replace the default spinner icon with any other icon by editing the Spinner component.

import { css, type Handle } from 'remix/component'

import { LoaderIcon, type IconProps } from '#app/ui/icons.tsx'

const spin = css({ animation: 'spin 1s linear infinite' })
import { css, type Handle } from 'remix/component'

import { LoaderIcon, type IconProps } from '#app/ui/icons.tsx'

const spin = css({ animation: 'spin 1s linear infinite' })

function Spinner(handle: Handle<IconProps>) {
  return () => {
    let { mix, ...props } = handle.props
    return (
      <LoaderIcon
        role="status"
        aria-label="Loading"
        size={16}
        class="size-4"
        {...props}
        mix={[spin, mix]}
      />
    )
  }
}

export function SpinnerCustom() {
  return () => (
    <div mix={css({ display: 'flex', alignItems: 'center', gap: '1rem' })}>
      <Spinner />
    </div>
  )
}

Size

Use the size-* utility class to change the size of the spinner.

import { css } from 'remix/component'

import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerSize() {
  return () => (
import { css } from 'remix/component'

import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerSize() {
  return () => (
    <div mix={css({ display: 'flex', alignItems: 'center', gap: '1.5rem' })}>
      <Spinner mix={css({ width: '0.75rem', height: '0.75rem' })} />
      <Spinner mix={css({ width: '1rem', height: '1rem' })} />
      <Spinner mix={css({ width: '1.5rem', height: '1.5rem' })} />
      <Spinner mix={css({ width: '2rem', height: '2rem' })} />
    </div>
  )
}

Button

Add a spinner to a button to indicate a loading state. Place the <Spinner /> before the label with data-icon="inline-start" for a start position, or after the label with data-icon="inline-end" for an end position.

import { css } from 'remix/component'

import { Button } from '#app/ui/button.tsx'
import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerButton() {
import { css } from 'remix/component'

import { Button } from '#app/ui/button.tsx'
import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerButton() {
  return () => (
    <div mix={css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' })}>
      <Button disabled size="sm">
        <Spinner />
        Loading...
      </Button>
      <Button variant="outline" disabled size="sm">
        <Spinner />
        Please wait
      </Button>
      <Button variant="secondary" disabled size="sm">
        <Spinner />
        Processing
      </Button>
    </div>
  )
}

Badge

Add a spinner to a badge to indicate a loading state. Place the <Spinner /> before the label with data-icon="inline-start" for a start position, or after the label with data-icon="inline-end" for an end position.

SyncingUpdatingProcessing
import { css } from 'remix/component'

import { Badge } from '#app/ui/badge.tsx'
import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerBadge() {
import { css } from 'remix/component'

import { Badge } from '#app/ui/badge.tsx'
import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerBadge() {
  return () => (
    <div mix={css({ display: 'flex', alignItems: 'center', gap: '1rem', '--radius': '1.2rem' })}>
      <Badge>
        <Spinner />
        Syncing
      </Badge>
      <Badge variant="secondary">
        <Spinner />
        Updating
      </Badge>
      <Badge variant="outline">
        <Spinner />
        Processing
      </Badge>
    </div>
  )
}

Input Group

Validating...
import { css } from 'remix/component'

import { ArrowUpIcon } from '#app/ui/icons.tsx'
import {
  InputGroup,
  InputGroupAddon,
import { css } from 'remix/component'

import { ArrowUpIcon } from '#app/ui/icons.tsx'
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupTextarea,
} from '#app/ui/input-group.tsx'
import { Spinner } from '#app/ui/spinner.tsx'

const srOnly = css({
  position: 'absolute',
  width: '1px',
  height: '1px',
  padding: 0,
  margin: '-1px',
  overflow: 'hidden',
  clip: 'rect(0, 0, 0, 0)',
  whiteSpace: 'nowrap',
  borderWidth: 0,
})

export function SpinnerInputGroup() {
  return () => (
    <div
      mix={css({
        display: 'flex',
        width: '100%',
        maxWidth: '28rem',
        flexDirection: 'column',
        gap: '1rem',
      })}
    >
      <InputGroup>
        <InputGroupInput placeholder="Send a message..." disabled />
        <InputGroupAddon align="inline-end">
          <Spinner />
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <InputGroupTextarea placeholder="Send a message..." disabled />
        <InputGroupAddon align="block-end">
          <Spinner /> Validating...
          <InputGroupButton mix={css({ marginLeft: 'auto' })} variant="default">
            <ArrowUpIcon />
            <span mix={srOnly}>Send</span>
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </div>
  )
}

Empty

Processing your request
Please wait while we process your request. Do not refresh the page.
import { css } from 'remix/component'

import { Button } from '#app/ui/button.tsx'
import {
  Empty,
  EmptyContent,
import { css } from 'remix/component'

import { Button } from '#app/ui/button.tsx'
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '#app/ui/empty.tsx'
import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerEmpty() {
  return () => (
    <Empty mix={css({ width: '100%' })}>
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <Spinner />
        </EmptyMedia>
        <EmptyTitle>Processing your request</EmptyTitle>
        <EmptyDescription>
          Please wait while we process your request. Do not refresh the page.
        </EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button variant="outline" size="sm">
          Cancel
        </Button>
      </EmptyContent>
    </Empty>
  )
}

Basic

import { css } from 'remix/component'

import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerBasic() {
  return () => (
import { css } from 'remix/component'

import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerBasic() {
  return () => (
    <div
      mix={css({
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        gap: '2rem',
      })}
    >
      <Spinner />
    </div>
  )
}

Color

import { css } from 'remix/component'

import { Spinner } from '#app/ui/spinner.tsx'

const size6 = css({ width: '1.5rem', height: '1.5rem' })
import { css } from 'remix/component'

import { Spinner } from '#app/ui/spinner.tsx'

const size6 = css({ width: '1.5rem', height: '1.5rem' })

export function SpinnerColor() {
  return () => (
    <div mix={css({ display: 'flex', alignItems: 'center', gap: '1.5rem' })}>
      <Spinner mix={[size6, css({ color: 'oklch(63.7% 0.237 25.331)' })]} />
      <Spinner mix={[size6, css({ color: 'oklch(72.3% 0.219 149.579)' })]} />
      <Spinner mix={[size6, css({ color: 'oklch(62.3% 0.214 259.815)' })]} />
      <Spinner mix={[size6, css({ color: 'oklch(79.5% 0.184 86.047)' })]} />
      <Spinner mix={[size6, css({ color: 'oklch(62.7% 0.265 303.9)' })]} />
    </div>
  )
}

Item

Downloading...

129 MB / 1000 MB

import { css } from 'remix/component'

import { Button } from '#app/ui/button.tsx'
import {
  Item,
  ItemActions,
import { css } from 'remix/component'

import { Button } from '#app/ui/button.tsx'
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemFooter,
  ItemMedia,
  ItemTitle,
} from '#app/ui/item.tsx'
import { Progress } from '#app/ui/progress.tsx'
import { Spinner } from '#app/ui/spinner.tsx'

export function SpinnerItem() {
  return () => (
    <div
      mix={css({
        display: 'flex',
        width: '100%',
        maxWidth: '28rem',
        flexDirection: 'column',
        gap: '1rem',
        '--radius': '1rem',
      })}
    >
      <Item variant="outline">
        <ItemMedia variant="icon">
          <Spinner />
        </ItemMedia>
        <ItemContent>
          <ItemTitle>Downloading...</ItemTitle>
          <ItemDescription>129 MB / 1000 MB</ItemDescription>
        </ItemContent>
        <ItemActions
          mix={css({ display: 'none', '@media (min-width: 40rem)': { display: 'flex' } })}
        >
          <Button variant="outline" size="sm">
            Cancel
          </Button>
        </ItemActions>
        <ItemFooter>
          <Progress value={75} />
        </ItemFooter>
      </Item>
    </div>
  )
}

API Reference

Spinner

shadcn's size-4 animate-spin. The default 16px size is the SVG's width/height attributes, so parent rules such as Badge's [&>svg]:size-3 and any mix size win over it, exactly like Tailwind specificity does for size-4. The size-4 class keeps the spinner out of [class*="size-"] icon-sizing rules (Button, Empty, Item…), as in shadcn.

PropTypeDefault
sizestring | number-
strokeWidthstring | number-
mixMixValue-