Spinner

A spinner is an animated spinning icon that lets users know content is being loaded.

Default

The default form of spinner.

import React from 'react'; import Spinner from '@atlaskit/spinner/spinner'; export default (): React.JSX.Element => ( <Spinner testId="spinner" interactionName="load" label="Loading" /> );

Sizes

  • Extra small: Use this size inside toggles.
  • Small: Use within elements such as buttons or form fields, or when there are other space constraints.
  • Medium: The default size. We recommend using the medium size for most use cases.
  • Large
  • Extra large
xsmall
small
medium
large
xlarge
custom
import { css, jsx } from '@compiled/react'; import Lozenge from '@atlaskit/lozenge/lozenge'; import Spinner from '@atlaskit/spinner/spinner'; import type { Size } from '@atlaskit/spinner/types'; import { token } from '@atlaskit/tokens'; const sizes: Size[] = ['xsmall', 'small', 'medium', 'large', 'xlarge', 80]; const containerStyles = css({ display: 'flex', gap: token('space.200'), flexWrap: 'wrap', }); const itemStyles = css({ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: token('space.100'), flexDirection: 'column', }); export default function Example(): JSX.Element { return ( <div css={containerStyles}> {sizes.map((size: Size) => ( <div key={size} css={itemStyles}> <Spinner size={size} label="Loading" /> {typeof size === 'number' ? ( <Lozenge appearance="discovery">custom</Lozenge> ) : ( <Lozenge appearance="success">{size}</Lozenge> )} </div> ))} </div> ); }

Animation

A spinner will always animate itself in. For graceful exit animations we recommend that you use <FadeIn /> from motion

No exit animation

With cross fading

import React, { useEffect, useState } from 'react'; import { css, jsx } from '@compiled/react'; import Avatar from '@atlaskit/avatar/avatar'; import Button from '@atlaskit/button/default/button'; import ExitingPersistence from '@atlaskit/motion/exiting-persistence'; import FadeIn from '@atlaskit/motion/fade-in'; import Spinner from '@atlaskit/spinner/spinner'; import { token } from '@atlaskit/tokens'; import VisuallyHidden from '@atlaskit/visually-hidden/visually-hidden'; type Phase = 'stopped' | 'loading' | 'ready'; const layoutStyles = css({ display: 'flex', justifyContent: 'center', gap: token('space.200'), }); const columnStyles = css({ display: 'flex', alignItems: 'center', flexDirection: 'column', }); const headingStyles = css({ marginBlockEnd: token('space.200'), }); const loadingContainerStyles = css({ display: 'flex', width: 200, height: 200, alignItems: 'center', justifyContent: 'center', }); const spinnerStyles = css({ position: 'absolute' }); function Harness({ children, title, buttonLabel, }: { children: (phase: Phase) => React.ReactElement; title: string; buttonLabel: string; }) { const [phase, setPhase] = useState<Phase>('stopped'); const liveRegionAnnouncement = (() => { switch (phase) { case 'loading': return 'Loading'; case 'ready': return 'Avatar loading completed'; default: return null; } })(); useEffect( function onPhaseChange() { if (phase === 'loading') { const id = window.setTimeout(() => setPhase('ready'), 2000); return () => window.clearTimeout(id); } }, [phase], ); return ( <div css={columnStyles}> <h4 css={headingStyles}>{title}</h4> <VisuallyHidden> <div aria-live="polite">{liveRegionAnnouncement}</div> </VisuallyHidden> <Button onClick={() => setPhase('loading')} isDisabled={phase === 'loading'}> {buttonLabel} </Button> <div css={loadingContainerStyles}>{children(phase)}</div> </div> ); } function NotAnimated() { return ( <Harness title="No exit animation" buttonLabel="Load avatar without animation"> {(phase: Phase) => ( <React.Fragment> {phase === 'ready' && <Avatar size="xlarge" />} {phase === 'loading' && ( <span css={spinnerStyles}> <Spinner size="xlarge" label="Loading" /> </span> )} </React.Fragment> )} </Harness> ); } function Animated() { return ( <Harness title="With cross fading" buttonLabel="Load avatar with animation"> {(phase: Phase) => ( <React.Fragment> <ExitingPersistence appear> {phase === 'ready' && ( <FadeIn> {(props) => ( <span {...props}> <Avatar size="xlarge" /> </span> )} </FadeIn> )} </ExitingPersistence> <ExitingPersistence> {phase === 'loading' && ( <FadeIn onFinish={(value) => console.log('fade in finished', value)}> {(props) => ( <span {...props} css={spinnerStyles}> <Spinner size="xlarge" label="Loading" /> </span> )} </FadeIn> )} </ExitingPersistence> </React.Fragment> )} </Harness> ); } export default function Example(): JSX.Element { return ( <div css={layoutStyles}> <NotAnimated /> <Animated /> </div> ); }

Spinner over content

When using a spinner directly over content, apply the opacity.loading token to the content container to de-emphasize the content and increase the visibility of the spinner.

NameSizeLast commitMessage
.editorconfig189 B2018-02-97Add .editorconfig to easily configure standard editor settings
.eslintignore1.21 KB2022-08-17DSP-3204 chore: deleted icon-priority
eslint.config.cjs28.62 KB2022-08-17DSP-3204 chore: deleted icon-priority
.gitattributes951 B2022-09-05DSP-6586 add correct docs delta
.gitignore2.67 KB2022-09-12NO-ISSUE scope gitignore reports to contact folder
import React, { useState } from 'react'; import Button from '@atlaskit/button/default/button'; import { DynamicTableStateless } from '@atlaskit/dynamic-table'; import { type HeadType, type RowType } from '@atlaskit/dynamic-table/types'; const head: HeadType = { cells: [ { key: 'name', content: 'Name', }, { key: 'size', content: 'Size', }, { key: 'last-commit', content: 'Last commit', }, { key: 'message', content: 'Message', }, ], }; const rows: RowType[] = [ { cells: [ { content: '.editorconfig' }, { content: '189 B' }, { content: '2018-02-97' }, { content: 'Add .editorconfig to easily configure standard editor settings', }, ], }, { cells: [ { content: '.eslintignore' }, { content: '1.21 KB' }, { content: '2022-08-17' }, { content: 'DSP-3204 chore: deleted icon-priority', }, ], }, { cells: [ { content: 'eslint.config.cjs' }, { content: '28.62 KB' }, { content: '2022-08-17' }, { content: 'DSP-3204 chore: deleted icon-priority', }, ], }, { cells: [ { content: '.gitattributes' }, { content: '951 B' }, { content: '2022-09-05' }, { content: 'DSP-6586 add correct docs delta', }, ], }, { cells: [ { content: '.gitignore' }, { content: '2.67 KB' }, { content: '2022-09-12' }, { content: 'NO-ISSUE scope gitignore reports to contact folder', }, ], }, ]; const SpinnerOverContentExample = (): React.JSX.Element => { const [isLoading, setIsLoading] = useState(false); return ( <> <Button onClick={() => setIsLoading((loading) => !loading)}>Toggle loading</Button> <DynamicTableStateless head={head} rows={rows} rowsPerPage={5} page={1} isLoading={isLoading} /> </> ); }; export default SpinnerOverContentExample;

Delaying a spinner

Sometimes you might want to delay showing a spinner when loading something asynchronously.

Spinner flashing

The <Spinner /> is only visible to a user after 150-200ms of being rendered because of it's opacity fade in. There is no need to delay a spinner to prevent it quickly flashing on an initial load. If you are concerned about having a spinner flash quickly and then harshly be removed, we recommend that you use <FadeIn /> from motion to gracefully animate the unmount of a spinner.

Long pausing

Sometimes you will want to delay a spinner from showing for a longer period of time.

A spinner has a delay prop that can be used to achieve a long pause. You can set the value to 500-1000+ ms to prevent a spinner from being shown for a longer period of time.

For best results, use <FadeIn /> from motion to fade out the spinner. It's still possible for the spinner to show briefly when your async operation takes slightly longer than your long delay. Fading out your spinner will always look best.

Content load time: small (50ms)
Spinner delay: none (default)

Default

No fadeout of spinner

Cross fade

Cross fading out the spinners exit with content

import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { css, jsx } from '@compiled/react'; import Avatar from '@atlaskit/avatar/avatar'; import Button from '@atlaskit/button/default/button'; import { Label } from '@atlaskit/form/label/default'; import ExitingPersistence from '@atlaskit/motion/exiting-persistence'; import FadeIn from '@atlaskit/motion/fade-in'; import Select from '@atlaskit/select/default'; import type { ValueType } from '@atlaskit/select/types'; import Spinner from '@atlaskit/spinner/spinner'; import { token } from '@atlaskit/tokens'; type Delays = { spinner: number; content: number; }; const DelayContext = React.createContext<Delays>({ spinner: 0, content: 0 }); type Phase = 'stopped' | 'loading' | 'ready'; const layoutStyles = css({ display: 'grid', justifyContent: 'center', gridTemplateColumns: 'repeat(auto-fit, minmax(0, 300px))', marginBlockStart: token('space.400'), }); const controlContainerStyles = css({ display: 'flex', maxWidth: 300, margin: '0 auto', gap: token('space.100'), flexDirection: 'column', }); const columnStyles = css({ display: 'flex', alignItems: 'center', flexDirection: 'column', textAlign: 'center', }); const columnInfoStyles = css({ minHeight: 70 }); const loadingContainerStyles = css({ display: 'flex', width: 200, height: 200, alignItems: 'center', justifyContent: 'center', }); const spinnerStyles = css({ position: 'absolute' }); function Harness({ children, title, description, }: { children: (phase: Phase, delays: Delays) => React.ReactElement; title: string; description: string; }) { const [phase, setPhase] = useState<Phase>('stopped'); const delays: Delays = useContext(DelayContext); useEffect( function onPhaseChange() { if (phase === 'loading') { const id = window.setTimeout(() => setPhase('ready'), delays.content); return () => window.clearTimeout(id); } }, [delays.content, phase], ); return ( <div css={columnStyles}> <h4>{title}</h4> <p css={columnInfoStyles}>{description}</p> <Button onClick={() => setPhase('loading')} isDisabled={phase === 'loading'}> {phase === 'loading' ? 'running' : 'start'} </Button> <div css={loadingContainerStyles}>{children(phase, delays)}</div> </div> ); } function Basic() { return ( <Harness title="Default" description="No fadeout of spinner"> {(phase: Phase, delays: Delays) => ( <React.Fragment> {phase === 'ready' && <Avatar size="xlarge" />} {phase === 'loading' && ( <span css={spinnerStyles}> <Spinner size="xlarge" delay={delays.spinner} label="Loading" /> </span> )} </React.Fragment> )} </Harness> ); } function CrossFade() { return ( <Harness title="Cross fade" description="Cross fading out the spinners exit with content"> {(phase: Phase, delays: Delays) => ( <React.Fragment> <ExitingPersistence appear> {phase === 'ready' && ( <FadeIn> {(props) => ( <span {...props}> <Avatar size="xlarge" /> </span> )} </FadeIn> )} </ExitingPersistence> <ExitingPersistence> {phase === 'loading' && ( <FadeIn> {(props) => ( <span {...props} css={spinnerStyles}> <Spinner size="xlarge" delay={delays.spinner} label="Loading" /> </span> )} </FadeIn> )} </ExitingPersistence> </React.Fragment> )} </Harness> ); } type Option = { value: string; label: string }; const contentDelayOptions: Option[] = [ { value: '10', label: 'Content load time: tiny (10ms)' }, { value: '50', label: 'Content load time: small (50ms)' }, { value: '100', label: 'Content load time: medium (100ms)' }, { value: '500', label: 'Content load time: long (500ms)' }, { value: '2000', label: 'Content load time: super long (2000ms)' }, ]; const defaultContentDelay: Option = contentDelayOptions[1]; const spinnerDelayOptions: Option[] = [ { value: '0', label: 'Spinner delay: none (default)' }, { value: '100', label: 'Spinner delay: too short (100ms)' }, { value: '500', label: 'Spinner delay: medium (500ms)' }, { value: '1000', label: 'Spinner delay: long (1000ms)' }, ]; const defaultSpinnerDelay: Option = spinnerDelayOptions[0]; function Example() { const [contentDelay, setContentDelay] = useState(Number(defaultContentDelay.value)); const onContentDelayChange = useCallback((result: ValueType<Option>) => { if (result != null && !Array.isArray(result)) { // doing a cast to Option as Array.isArray is not narrowing the type setContentDelay(Number((result as Option).value)); } }, []); const [spinnerDelay, setSpinnerDelay] = useState(Number(defaultContentDelay.value)); const onSpinnerDelayChange = useCallback((result: ValueType<Option>) => { if (result != null && !Array.isArray(result)) { // doing a cast to Option as Array.isArray is not narrowing the type setSpinnerDelay(Number((result as Option).value)); } }, []); const delay: Delays = useMemo( () => ({ content: contentDelay, spinner: spinnerDelay, }), [contentDelay, spinnerDelay], ); return ( <DelayContext.Provider value={delay}> <div css={controlContainerStyles}> <div> <Label htmlFor="input-content-delay-options">Content delay options</Label> <Select inputId="input-content-delay-options" options={contentDelayOptions} defaultValue={defaultContentDelay} onChange={onContentDelayChange} /> </div> <div> <Label htmlFor="input-spinner-delay-options">Spinner delay options</Label> <Select inputId="input-spinner-delay-options" options={spinnerDelayOptions} defaultValue={defaultSpinnerDelay} onChange={onSpinnerDelayChange} /> </div> </div> <div css={layoutStyles}> <Basic /> <CrossFade /> </div> </DelayContext.Provider> ); } export default (): JSX.Element => <Example />;
Was this page helpful?
We use this feedback to improve our documentation.
© 2026 AtlassianTrademark, (opens new window)Privacy, (opens new window)License