Skip to main contentSkip to docs navigation

Loading States

Swap each piece of a component's real content for a Skeleton via SkeletonLoader, keeping the same structure and props for the loading state.

On this page

Neither <Card> nor <Table> has a loading prop of its own, and <Skeleton> doesn't wrap or replace a whole component either — it's a content-level primitive, the same way a real card's title is just text. A loading state is built the same way as the real one, using the same props: <Card title={…} subtitle={…} text={…}>, where each value is either the real thing or a <SkeletonLoader> rendering a matching skeleton in its place. Application state (here, a loading boolean) decides which each one renders — no separate skeleton tree, and no manual composition, to keep in sync by hand.

Card

The card example passes a SkeletonLoader straight into Card's title, subtitle, and text shorthand props, so the same prop-driven markup renders both the loading and loaded states.

Loading product…
JSX
import { useEffect, useState } from 'react'
import { Button, Card, CardImage, Skeleton, SkeletonLoader } from '@chassis-ui/react'

const FETCH_DELAY = 1500

export const Example = () => {
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    if (!loading) return
    const timeout = setTimeout(() => setLoading(false), FETCH_DELAY)
    return () => clearTimeout(timeout)
  }, [loading])

  return (
    <div className="vstack gap-md" style={{ maxWidth: '20rem' }}>
      <span className="visually-hidden" aria-live="polite">
        {loading ? 'Loading product…' : 'Product loaded'}
      </span>
      <Card
        className={loading ? 'skeleton-glow' : undefined}
        aria-hidden={loading || undefined}
        // `image` accepts a `src` string (auto-wrapped in `CardImage`) or a full element — here,
        // a `Skeleton`-shaped `CardImage` while loading, since `CardImage` always needs a real
        // `src` and can't be swapped through `Skeleton`'s own `component` the way text can.
        image={
          loading ? (
            <CardImage component={Skeleton} orientation="top" style={{ aspectRatio: '2 / 1' }} />
          ) : (
            'https://placehold.co/800x400'
          )
        }
        imageAlt=""
        title={
          <SkeletonLoader loading={loading} spans={6}>
            Denim Jacket
          </SkeletonLoader>
        }
        subtitle={
          <SkeletonLoader loading={loading} spans={4}>
            $89.00
          </SkeletonLoader>
        }
        text={
          <SkeletonLoader loading={loading} spans={[12, 9, 5]}>
            Classic fit, stonewashed denim, available in three colors.
          </SkeletonLoader>
        }
      />
      <Button onClick={() => setLoading(true)} disabled={loading}>
        Reload
      </Button>
    </div>
  )
}

Profile

<SkeletonLoader> isn't text-only — its component prop works exactly like <Skeleton>'s own, so the same swap-in-place pattern covers an avatar and a button too, not just the two text runs.

Loading profile…
JSX
import { useEffect, useState } from 'react'
import { Avatar, Button, SkeletonLoader } from '@chassis-ui/react'

const FETCH_DELAY = 1500

const user = {
  name: 'Jordan Lee',
  avatar: 'https://i.pravatar.cc/80'
}

export const Example = () => {
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    if (!loading) return
    const timeout = setTimeout(() => setLoading(false), FETCH_DELAY)
    return () => clearTimeout(timeout)
  }, [loading])

  return (
    <div className="vstack gap-md" style={{ maxWidth: '20rem' }}>
      <span className="visually-hidden" aria-live="polite">
        {loading ? 'Loading profile…' : 'Profile loaded'}
      </span>
      <div
        className={`d-flex align-items-center gap-md${loading ? ' skeleton-glow' : ''}`}
        aria-hidden={loading || undefined}
      >
        <SkeletonLoader loading={loading} component={Avatar} size="lg">
          <Avatar src={user.avatar} alt={user.name} size="lg" />
        </SkeletonLoader>
        {/* The growable wrapper stays in place across both states — a class passed straight to
            `SkeletonLoader` would vanish once `loading` is `false`, since it renders `children`
            with no wrapper of its own then. */}
        <div className="font-lg flex-grow-1">
          <SkeletonLoader loading={loading} spans={6}>
            {user.name}
          </SkeletonLoader>
        </div>
        <SkeletonLoader loading={loading} component={Button} spans={3} disabled>
          <Button>Follow</Button>
        </SkeletonLoader>
      </div>
      <Button onClick={() => setLoading(true)} disabled={loading} className="align-self-start">
        Reload
      </Button>
    </div>
  )
}

Table

The table example applies the same swap-in-place pattern per cell, rendering one SkeletonLoader in each TableCell in place of that cell's real value.

Loading recent orders…
OrderCustomerAmountStatus
JSX
import { useEffect, useState } from 'react'
import {
  Badge,
  Button,
  SkeletonLoader,
  Table,
  TableHeader,
  TableColumn,
  TableBody,
  TableRow,
  TableCell
} from '@chassis-ui/react'

const FETCH_DELAY = 1500

const orders = [
  { id: '#1042', customer: 'Alice Martin', amount: '$120.00', status: 'Paid' },
  { id: '#1043', customer: 'Bob Chen', amount: '$85.50', status: 'Pending' },
  { id: '#1044', customer: 'Carol White', amount: '$240.00', status: 'Paid' }
] as const

const columns = [
  { key: 'id', label: 'Order', width: 2, span: 4 },
  { key: 'customer', label: 'Customer', width: 4, span: 8 },
  { key: 'amount', label: 'Amount', width: 2, span: 5 },
  { key: 'status', label: 'Status', width: 2, span: 4 }
]

const statusColor = { Paid: 'success', Pending: 'warning' } as const

export const Example = () => {
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    if (!loading) return
    const timeout = setTimeout(() => setLoading(false), FETCH_DELAY)
    return () => clearTimeout(timeout)
  }, [loading])

  return (
    <div className="vstack gap-md">
      <span className="visually-hidden" aria-live="polite">
        {loading ? 'Loading recent orders…' : 'Recent orders loaded'}
      </span>
      {/* `key` forces a remount when `loading` flips — `Table` builds its row/cell collection
          once via `useTableState` and won't otherwise notice a value that changed only inside a
          cell's render closure. */}
      <Table
        aria-label="Recent orders"
        className={`align-middle${loading ? ' skeleton-glow' : ''}`}
        key={String(loading)}
      >
        <TableHeader columns={columns}>
          {(column) => (
            <TableColumn className={`w-${column.width}/12`} key={column.key}>
              {column.label}
            </TableColumn>
          )}
        </TableHeader>
        <TableBody items={orders}>
          {(row) => (
            <TableRow key={row.id}>
              {(columnKey) => {
                const column = columns.find((c) => c.key === columnKey)!
                return (
                  <TableCell>
                    <SkeletonLoader
                      loading={loading}
                      spans={column.span}
                      component={columnKey === 'status' ? Badge : undefined}
                      color={columnKey === 'status' ? 'default' : undefined}
                    >
                      {columnKey === 'status' ? (
                        <Badge color={statusColor[row.status]}>{row.status}</Badge>
                      ) : (
                        row[columnKey as keyof typeof row]
                      )}
                    </SkeletonLoader>
                  </TableCell>
                )
              }}
            </TableRow>
          )}
        </TableBody>
      </Table>
      <Button onClick={() => setLoading(true)} disabled={loading} className="align-self-start">
        Reload
      </Button>
    </div>
  )
}

Key points

  • <SkeletonLoader loading={loading} spans={[…]}>{realValue}</SkeletonLoader> replaces one run of text in place — same position in the tree either way, no wrapping element, no parallel structure to maintain. The card example passes one straight into Card's title/subtitle/text shorthand props, so the whole card stays a single prop-driven component with no manual CardBody/CardTitle composition.
  • For non-text content, pass component (and that component's own props, e.g. size for <Avatar> or disabled for <Button>) straight to <SkeletonLoader>, same as <Skeleton> — see the profile example. The one exception is <CardImage>: it always needs a real src, so it can't be swapped through SkeletonLoader/Skeleton's component the same way — the card example instead passes a ternary straight into Card's image prop, which accepts either a src string or a full element (a <CardImage component={Skeleton}>, a <Placeholder>, anything) rendered as-is.
  • animation="glow"/.skeleton-glow goes on a container you already have (<Card>, the profile row, <Table> itself), not on SkeletonLoader — it has no element of its own to put a class on. .skeleton-glow pulses via a descendant selector (.skeleton-glow .skeleton { animation }), so one class on an ancestor reaches every generated skeleton inside, at any depth. .skeleton-wave works differently — it masks the container's own rendered output directly, sweeping across whatever .skeleton descendants it contains — but the practical instruction is the same either way: put the class on the container, not on individual skeletons.
  • Pick spans values that roughly match the eventual text's length per line — exact precision doesn't matter, just enough to avoid a jarring width jump once real data replaces it. Leave spans unset entirely for non-text swaps whose target already sizes itself, like the profile example's avatar.
  • The table example still keys <Table> on String(loading) to force a remount when it flips — Table builds its row/cell collection once via useTableState and won't otherwise notice a value that only changed inside a cell's render closure. This is a Table-specific quirk, not something SkeletonLoader needs elsewhere.
  • All three examples simulate a fetch with setTimeout and a "Reload" button. Swap that for loading state driven by your actual data-fetching hook (useQuery, useEffect + fetch, etc.).
  • aria-hidden on the loading container only hides the skeleton from assistive technology — it doesn't announce when the real content shows up. All three examples pair it with a .visually-hidden aria-live="polite" span that reports the state change out loud, since nothing else in any of them does. It's placed once per example, not once per <SkeletonLoader> — the table has one <SkeletonLoader> per cell but only one announcer for the whole table, so reloading doesn't fire a burst of duplicate announcements.