Spinner
An indicator that can be used to show a loading state.
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
$ bunx --bun shadcn@latest add https://www.shadcn-remix.com/r/spinner.json$ npx shadcn@latest add https://www.shadcn-remix.com/r/spinner.json$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/spinner.json$ yarn dlx shadcn@latest add https://www.shadcn-remix.com/r/spinner.jsonInstall the following dependencies:
$ bun add remix$ npm install remix$ pnpm add remix$ yarn add remixCopy and paste the following code into your project.
import { css, type Handle } from 'remix/component'
import { Loader2Icon, type IconProps } from './icons.tsx'
const spin = css({ animation: 'spin 1s linear infinite' })
/**
* 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.
*/
export function Spinner(handle: Handle<IconProps>) {
return () => {
let { class: className, mix, ...props } = handle.props
return (
<Loader2Icon
role="status"
aria-label="Loading"
size={16}
class={className ? `size-4 ${className}` : 'size-4'}
{...props}
mix={[spin, mix]}
/>
)
}
}Update the import paths to match your project setup.
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.
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
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
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
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.
| Prop | Type | Default |
|---|---|---|
size | string | number | - |
strokeWidth | string | number | - |
mix | MixValue | - |