# Data table

Sortable, selectable, paginated table - header sort buttons, optional row select.

Preview: https://design.freecodecamp.org/playground#data-table

## Add to your project

Use React and TypeScript. Required packages: `react@>=18 <20`. No freeCodeCamp package is needed.

Copy the files below to the indicated paths, relative to your project root. If you change the layout, update relative imports too.

Import the CSS once from your application entry. For an entry in src/:

```ts
import './ui/theme/tokens.css';
import './ui/theme/base.css';
import './ui/data-table/data-table.css';
import './ui/skeleton/skeleton.css';
```

The theme is shared: reuse it if already installed. Fonts use /fonts/ URLs on your host. Download the font files listed in https://design.freecodecamp.org/registry/starter.md or change those URLs in your copied tokens.css.

## Example

```tsx
import { DataTable, type DataTableSort } from './ui/data-table/DataTable';
import { useState } from 'react';

const rows = [
  { id: 'rwd', cert: 'Responsive Web Design', hours: 300 },
  { id: 'js', cert: 'JavaScript', hours: 300 }
];

export function Certifications() {
  const [sortBy, setSortBy] = useState<DataTableSort | null>(null);
  const sorted = [...rows].sort((a, b) =>
    sortBy
      ? a.cert.localeCompare(b.cert) * (sortBy.direction === 'asc' ? 1 : -1)
      : 0
  );
  return (
    <DataTable
      columns={[
        {
          id: 'cert',
          accessor: 'cert',
          header: 'Certification',
          sortable: true
        },
        { id: 'hours', accessor: 'hours', header: 'Hours', align: 'right' }
      ]}
      rows={sorted}
      sortBy={sortBy}
      onSortChange={setSortBy}
    />
  );
}
```

## Interaction guidance

Review https://www.w3.org/WAI/ARIA/apg/patterns/grid/ and test keyboard operation in your project.

## Component source

### src/ui/data-table/DataTable.tsx

Source: https://design.freecodecamp.org/registry/data-table/DataTable.tsx

```tsx
import React from 'react';

export type DataTableAlign = 'left' | 'right' | 'center';

export interface DataTableColumn<TRow> {
  id: string;
  header: React.ReactNode;
  /** `string` reads `row[accessor]`; function maps the row to a cell value. */
  accessor: keyof TRow | ((row: TRow) => React.ReactNode);
  sortable?: boolean;
  align?: DataTableAlign;
  width?: number | string;
}

export interface DataTableSort {
  columnId: string;
  direction: 'asc' | 'desc';
}

export interface DataTableProps<TRow> {
  columns: readonly DataTableColumn<TRow>[];
  rows: readonly TRow[];
  /** Row id accessor. Defaults to `row.id`. */
  rowId?: (row: TRow) => string;
  sortBy?: DataTableSort | null;
  onSortChange?: (next: DataTableSort | null) => void;
  selection?: ReadonlySet<string>;
  onSelectionChange?: (next: Set<string>) => void;
  loading?: boolean;
  emptyState?: React.ReactNode;
  className?: string;
  caption?: React.ReactNode;
  /** Number of skeleton rows to emit while `loading`. */
  skeletonRows?: number;
}

const defaultRowId = <TRow,>(row: TRow): string =>
  String((row as unknown as { id?: unknown }).id ?? '');

const readCell = <TRow,>(
  row: TRow,
  column: DataTableColumn<TRow>
): React.ReactNode => {
  if (typeof column.accessor === 'function') return column.accessor(row);
  return (row as unknown as Record<string, React.ReactNode>)[
    column.accessor as string
  ];
};

const nextDirection = (
  current: DataTableSort | null | undefined,
  columnId: string
): DataTableSort | null => {
  if (!current || current.columnId !== columnId) {
    return { columnId, direction: 'asc' };
  }
  if (current.direction === 'asc') {
    return { columnId, direction: 'desc' };
  }
  // Third click clears the sort - matches common table UX.
  return null;
};

const toWidth = (v: number | string | undefined): string | undefined =>
  typeof v === 'number' ? `${v}px` : v;

export const DataTable = <TRow,>({
  columns,
  rows,
  rowId = defaultRowId,
  sortBy,
  onSortChange,
  selection,
  onSelectionChange,
  loading = false,
  emptyState,
  className = '',
  caption,
  skeletonRows = 3
}: DataTableProps<TRow>): React.ReactElement => {
  const classes = ['data-table', className].filter(Boolean).join(' ');
  const hasSelection =
    selection !== undefined && onSelectionChange !== undefined;
  const totalCols = columns.length + (hasSelection ? 1 : 0);
  const allIds = rows.map(row => rowId(row));
  const allSelected =
    hasSelection && allIds.length > 0 && allIds.every(id => selection.has(id));
  const someSelected =
    hasSelection && !allSelected && allIds.some(id => selection.has(id));

  const toggleAll = (): void => {
    if (!hasSelection) return;
    const next = new Set(selection);
    if (allSelected) {
      allIds.forEach(id => next.delete(id));
    } else {
      allIds.forEach(id => next.add(id));
    }
    onSelectionChange(next);
  };
  const toggleRow = (id: string): void => {
    if (!hasSelection) return;
    const next = new Set(selection);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    onSelectionChange(next);
  };

  const renderCell = (
    row: TRow,
    column: DataTableColumn<TRow>
  ): React.ReactElement => {
    const cellClasses = [
      'data-table__cell',
      column.align && column.align !== 'left'
        ? `data-table__cell--${column.align}`
        : ''
    ]
      .filter(Boolean)
      .join(' ');
    return (
      <td
        key={column.id}
        className={cellClasses}
        style={
          column.width !== undefined
            ? { width: toWidth(column.width) }
            : undefined
        }
      >
        {readCell(row, column)}
      </td>
    );
  };

  return (
    <div className={classes}>
      <table className='data-table__table'>
        {caption !== undefined && <caption>{caption}</caption>}
        <thead>
          <tr>
            {hasSelection && (
              <th scope='col' className='data-table__select-all'>
                <input
                  type='checkbox'
                  aria-label='Select all rows'
                  checked={allSelected}
                  ref={el => {
                    if (el) el.indeterminate = someSelected;
                  }}
                  onChange={toggleAll}
                />
              </th>
            )}
            {columns.map(column => {
              const sortable = column.sortable === true;
              const active = sortBy?.columnId === column.id;
              const ariaSort: 'ascending' | 'descending' | 'none' | undefined =
                sortable
                  ? active
                    ? sortBy.direction === 'asc'
                      ? 'ascending'
                      : 'descending'
                    : 'none'
                  : undefined;
              const headerClasses = [
                'data-table__header',
                column.align && column.align !== 'left'
                  ? `data-table__header--${column.align}`
                  : ''
              ]
                .filter(Boolean)
                .join(' ');
              return (
                <th
                  key={column.id}
                  scope='col'
                  className={headerClasses}
                  aria-sort={ariaSort}
                  style={
                    column.width !== undefined
                      ? { width: toWidth(column.width) }
                      : undefined
                  }
                >
                  {sortable && onSortChange !== undefined ? (
                    <button
                      type='button'
                      className='data-table__sort-btn'
                      onClick={() =>
                        onSortChange(nextDirection(sortBy, column.id))
                      }
                    >
                      <span>{column.header}</span>
                      <span
                        className='data-table__sort-indicator'
                        aria-hidden='true'
                      >
                        {active
                          ? sortBy.direction === 'asc'
                            ? '▲'
                            : '▼'
                          : '↕'}
                      </span>
                    </button>
                  ) : (
                    column.header
                  )}
                </th>
              );
            })}
          </tr>
        </thead>
        <tbody>
          {loading
            ? Array.from({ length: skeletonRows }, (_, i) => (
                <tr key={`skel-${i}`} className='data-table__skeleton'>
                  {hasSelection && (
                    <td className='data-table__cell'>
                      <span className='skeleton' aria-hidden='true' />
                    </td>
                  )}
                  {columns.map(column => (
                    <td key={column.id} className='data-table__cell'>
                      <span className='skeleton' aria-hidden='true' />
                    </td>
                  ))}
                </tr>
              ))
            : rows.length === 0
              ? [
                  <tr key='empty' className='data-table__empty-row'>
                    <td colSpan={totalCols} className='data-table__empty-cell'>
                      {emptyState}
                    </td>
                  </tr>
                ]
              : rows.map(row => {
                  const id = rowId(row);
                  const selected = hasSelection && selection.has(id);
                  return (
                    <tr
                      key={id}
                      data-row-id={id}
                      data-selected={selected ? 'true' : undefined}
                    >
                      {hasSelection && (
                        <td className='data-table__cell data-table__select-cell'>
                          <input
                            type='checkbox'
                            aria-label={`Select row ${id}`}
                            checked={selected}
                            onChange={() => toggleRow(id)}
                          />
                        </td>
                      )}
                      {columns.map(column => renderCell(row, column))}
                    </tr>
                  );
                })}
        </tbody>
      </table>
    </div>
  );
};
DataTable.displayName = 'DataTable';
```

### src/ui/data-table/data-table.css

Source: https://design.freecodecamp.org/registry/data-table/data-table.css

```css
.data-table {
  width: 100%;
  overflow-x: auto;
  border: var(--border-width-hair) solid var(--foreground-secondary);
}
.data-table__table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-sm);
  color: var(--foreground-primary);
}
.data-table__table caption {
  padding: 8px 12px;
  text-align: left;
  font-size: var(--fs-sm);
  color: var(--foreground-secondary);
  border-bottom: var(--border-width-hair) solid var(--foreground-secondary);
}
.data-table__header {
  padding: 10px 12px;
  text-align: left;
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--foreground-secondary);
  background: var(--background-tertiary);
  border-bottom: var(--border-width-hair) solid var(--foreground-secondary);
}
.data-table__header--right {
  text-align: right;
}
.data-table__header--center {
  text-align: center;
}
.data-table__select-all {
  width: 36px;
  padding: 10px 12px;
  background: var(--background-tertiary);
  border-bottom: var(--border-width-hair) solid var(--foreground-secondary);
  text-align: center;
}
.data-table__sort-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: transparent;
  border: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
  text-transform: inherit;
  letter-spacing: inherit;
}
.data-table__sort-indicator {
  font-size: 10px;
  line-height: 1;
  opacity: 0.8;
}
.data-table__table tbody tr {
  border-top: var(--border-width-hair) solid var(--background-tertiary);
}
.data-table__table tbody tr[data-selected='true'] {
  background: var(--background-quaternary);
}
.data-table__cell {
  padding: 10px 12px;
  vertical-align: top;
  text-align: left;
}
.data-table__cell--right {
  text-align: right;
}
.data-table__cell--center {
  text-align: center;
}
.data-table__select-cell {
  width: 36px;
  text-align: center;
}
.data-table__skeleton .skeleton {
  height: 14px;
  width: 80%;
  display: block;
}
.data-table__empty-row {
  background: transparent;
}
.data-table__empty-cell {
  padding: 32px 16px;
  text-align: center;
  color: var(--foreground-secondary);
}
```

## Shared source: Theme

### src/ui/theme/tokens.css

Source: https://design.freecodecamp.org/registry/theme/tokens.css

```css
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Light.woff') format('woff');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Regular.woff') format('woff');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Italic.woff') format('woff');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Bold.woff') format('woff');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-BoldItalic.woff') format('woff');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Black.woff') format('woff');
  font-weight: 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Bold.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Italic.woff2') format('woff2');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-BoldItalic.woff2') format('woff2');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}

:root {
  --gray-00: #ffffff;
  --gray-00-translucent: rgba(255, 255, 255, 0.85);
  --gray-05: #f5f6f7;
  --gray-10: #dfdfe2;
  --gray-15: #d0d0d5;
  --gray-45: #858591;
  --gray-75: #3b3b4f;
  --gray-80: #2a2a40;
  --gray-85: #1b1b32;
  --gray-90: #0a0a23;
  --gray-90-translucent: rgba(10, 10, 35, 0.85);

  --purple-light: #dbb8ff;
  --purple-mid: #9400d3;
  --purple-dark: #5a01a7;
  --yellow-light: #ffc300;
  --yellow-gold: #ffbf00;
  --yellow-style: #f1be32;
  --yellow-dark: #4d3800;
  --blue-light: #99c9ff;
  --blue-light-translucent: rgba(153, 201, 255, 0.3);
  --blue-mid: #198eee;
  --blue-dark: #002ead;
  --blue-dark-translucent: rgba(0, 46, 173, 0.3);
  --green-light: #acd157;
  --green-dark: #00471b;
  --red-light: #ffadad;
  --red-dark: #850000;
  --love-light: #f8577c;
  --love-dark: #f82153;
  --orange: #eda971;

  --editor-background-light: #fffffe;
  --editor-background-dark: #2a2b40;

  --syntax-keyword: #dbb8ff;
  --syntax-fn: #99c9ff;
  --syntax-string: #acd157;
  --syntax-class: #f1be32;
  --syntax-number: #f78c6c;
  --syntax-tag: #f07178;
  --syntax-operator: #89ddff;
  --syntax-invalid: #ff5370;
  --syntax-comment: #858591;
  --syntax-plain: #eeffff;

  --font-sans:
    'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-mono: 'Hack-ZeroSlash', 'Fira Mono', Menlo, Consolas, monospace;

  --fs-base: 18px;
  --fs-sm: 16px;
  --fs-md: 18px;
  --fs-lg: 24px;
  --fs-xl: 32px;
  --fs-2xl: 42px;
  --fs-3xl: 56px;
  --fs-display: clamp(2.5rem, 5vw, 3.75rem);

  --lh-tight: 1.2;
  --lh-snug: 1.33;
  --lh-base: 1.42857143;
  --lh-loose: 1.6;

  --fw-light: 300;
  --fw-regular: 400;
  --fw-bold: 700;
  --fw-black: 900;

  --space-0: 0;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;

  --border-width-hair: 1px;
  --border-width-default: 2px;
  --border-width-thick: 3px;
  --radius-none: 0;
  --radius-sm: 2px;

  --focus-outline-color: var(--blue-mid);
  --focus-outline-width: 3px;

  --z-breadcrumbs: 100;
  --z-flash: 150;
  --z-site-header: 200;
  --z-modal: 1050;

  --ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-fast: 120ms;
  --dur-base: 180ms;
  --dur-slow: 260ms;

  --header-height: 48px;
  --breadcrumbs-height: 32px;
  --sidebar-width: 288px;
  --content-max: 1040px;

  color-scheme: dark;
}

.dark-palette,
:root {
  color-scheme: dark;
  --foreground-primary: var(--gray-00);
  --foreground-secondary: var(--gray-05);
  --foreground-tertiary: var(--gray-10);
  --foreground-quaternary: var(--gray-15);
  --foreground-muted: #b0b0bd;

  --background-primary: var(--gray-90);
  --background-primary-translucent: var(--gray-90-translucent);
  --background-secondary: var(--gray-85);
  --background-tertiary: #33334f;
  --background-quaternary: #4b4b66;

  --highlight-color: var(--blue-light);
  --highlight-background: var(--blue-dark);
  --selection-color: var(--blue-light-translucent);

  --success-color: var(--green-light);
  --success-background: var(--green-dark);
  --danger-color: var(--red-light);
  --danger-background: var(--red-dark);
  --warning-color: var(--yellow-light);
  --warning-background: var(--yellow-dark);
  --purple-color: var(--purple-light);
  --purple-background: var(--purple-dark);
  --love-color: var(--love-light);

  --editor-background: var(--editor-background-dark);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(255, 255, 255, 0.045);
  --surface-elevation-2: rgba(255, 255, 255, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);
}

.light-palette {
  --foreground-primary: var(--gray-90);
  --foreground-secondary: var(--gray-85);
  --foreground-tertiary: var(--gray-80);
  --foreground-quaternary: var(--gray-75);
  --foreground-muted: #5a5a68;

  --background-primary: var(--gray-00);
  --background-primary-translucent: var(--gray-00-translucent);
  --background-secondary: var(--gray-05);
  --background-tertiary: #c5c5cc;
  --background-quaternary: #a8a8b4;

  --highlight-color: var(--blue-dark);
  --highlight-background: var(--blue-light);
  --selection-color: var(--blue-dark-translucent);

  --success-color: var(--green-dark);
  --success-background: var(--green-light);
  --danger-color: var(--red-dark);
  --danger-background: var(--red-light);
  --warning-color: var(--yellow-dark);
  --warning-background: var(--yellow-light);
  --purple-color: var(--purple-dark);
  --purple-background: var(--purple-light);
  --love-color: var(--love-dark);

  --editor-background: var(--editor-background-light);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(10, 10, 35, 0.05);
  --surface-elevation-2: rgba(10, 10, 35, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);

  color-scheme: light;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-size: var(--fs-md);
  font-family: var(--font-sans);
  line-height: var(--lh-base);
  color: var(--foreground-primary);
  background: var(--background-primary);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-height) + 24px);
}

body {
  margin: 0;
  font-family: var(--font-sans);
  color: var(--foreground-primary);
  background: var(--background-primary);
}

::selection {
  background: var(--selection-color);
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-family: var(--font-sans);
  font-weight: var(--fw-bold);
  color: var(--foreground-primary);
  line-height: var(--lh-snug);
  margin: 0 0 12px 0;
}
h1 {
  font-size: var(--fs-3xl);
  line-height: var(--lh-tight);
  letter-spacing: -0.01em;
}
h2 {
  font-size: var(--fs-2xl);
  letter-spacing: -0.005em;
}
h3 {
  font-size: var(--fs-xl);
}
h4 {
  font-size: var(--fs-lg);
}
h5 {
  font-size: var(--fs-md);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
h6 {
  font-size: var(--fs-sm);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--foreground-muted);
  font-family: var(--font-mono);
}

p {
  margin: 0 0 12px 0;
}

a {
  color: var(--highlight-color);
  text-decoration: underline;
  text-underline-position: under;
  text-underline-offset: 0.1em;
}
a:hover {
  color: var(--foreground-primary);
}

code,
pre,
kbd,
samp {
  font-family: var(--font-mono);
  font-size: 16px;
}
code {
  background: var(--background-tertiary);
  color: var(--foreground-tertiary);
}
:not(pre) > code {
  border: 1px solid var(--background-quaternary);
  padding: 1px 4px;
  overflow-wrap: anywhere;
  word-break: break-word;
}
pre {
  background: var(--editor-background);
  color: var(--foreground-tertiary);
  padding: 14px 16px;
  font-size: 14px;
  line-height: var(--lh-base);
  max-width: 100%;
  overflow-x: auto;
  margin: 0;
}
pre code {
  display: block;
  width: max-content;
  min-width: 100%;
  background: transparent;
  border: 0;
  padding: 0;
}

:focus-visible {
  outline: var(--focus-outline-width) solid var(--focus-outline-color);
  outline-offset: 0;
}

hr {
  border: 0;
  border-top: 1px solid var(--background-quaternary);
  margin: 24px 0;
}

::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
::-webkit-scrollbar-track {
  background: var(--background-primary);
}
::-webkit-scrollbar-thumb {
  background: var(--background-quaternary);
  border: 2px solid var(--background-primary);
}
::-webkit-scrollbar-thumb:hover {
  background: var(--foreground-muted);
}
```

### src/ui/theme/base.css

Source: https://design.freecodecamp.org/registry/theme/base.css

```css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
```

## Shared source: skeleton

### src/ui/skeleton/Skeleton.tsx

Source: https://design.freecodecamp.org/registry/skeleton/Skeleton.tsx

```tsx
import React, { forwardRef } from 'react';

export type SkeletonVariant = 'rect' | 'circle' | 'text';

export interface SkeletonProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  'children'
> {
  variant?: SkeletonVariant;
  width?: number | string;
  height?: number | string;
  /** For variant="text": render N stacked line bars. */
  lines?: number;
  /** Screen-reader label announced via visually-hidden span. */
  label?: React.ReactNode;
}

const toSize = (v: number | string | undefined): string | undefined =>
  typeof v === 'number' ? `${v}px` : v;

export const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(
  (
    {
      variant = 'rect',
      width,
      height,
      lines = 1,
      label,
      className = '',
      style,
      ...rest
    },
    ref
  ) => {
    const classes = [
      'skeleton',
      variant !== 'rect' && `skeleton--${variant}`,
      className
    ]
      .filter(Boolean)
      .join(' ');
    const mergedStyle: React.CSSProperties = {
      ...(style ?? {}),
      ...(width !== undefined && { width: toSize(width) }),
      ...(height !== undefined && { height: toSize(height) })
    };
    const hasInlineStyle = Object.keys(mergedStyle).length > 0;
    const isMultilineText =
      variant === 'text' && typeof lines === 'number' && lines > 0;
    return (
      <div
        ref={ref}
        role='status'
        aria-busy='true'
        aria-live='polite'
        className={classes}
        style={hasInlineStyle ? mergedStyle : undefined}
        {...rest}
      >
        {isMultilineText &&
          Array.from({ length: lines as number }, (_, i) => (
            <span key={i} className='skeleton__line' aria-hidden='true' />
          ))}
        {label !== undefined && <span className='sr-only'>{label}</span>}
      </div>
    );
  }
);
Skeleton.displayName = 'Skeleton';
```

### src/ui/skeleton/skeleton.css

Source: https://design.freecodecamp.org/registry/skeleton/skeleton.css

```css
.skeleton {
  display: block;
  width: 100%;
  height: 16px;
  background: var(--background-tertiary);
  background-image: linear-gradient(
    90deg,
    var(--background-tertiary) 0%,
    var(--background-quaternary) 50%,
    var(--background-tertiary) 100%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.6s ease-in-out infinite;
  border-radius: 2px;
}
.skeleton--circle {
  width: 40px;
  height: 40px;
  border-radius: 9999px;
}
.skeleton--text {
  height: auto;
  min-height: 16px;
  background: transparent;
  animation: none;
  display: flex;
  flex-direction: column;
  gap: 8px;
  border-radius: 0;
}
.skeleton__line {
  display: block;
  height: 12px;
  background: var(--background-tertiary);
  background-image: linear-gradient(
    90deg,
    var(--background-tertiary) 0%,
    var(--background-quaternary) 50%,
    var(--background-tertiary) 100%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.6s ease-in-out infinite;
  border-radius: 2px;
}
.skeleton__line:last-child:not(:only-child) {
  width: 65%;
}
@keyframes skeleton-shimmer {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .skeleton,
  .skeleton__line {
    animation: none;
  }
}
```

## Adapting this component

Keep the component's semantics and keyboard behavior. Use the CSS variables to change its appearance. Check the result in your project; copied source does not receive automatic updates.

Source revision: 1914914 (2026-09-08). Component source: BSD-3-Clause. Preserve the license notice: https://design.freecodecamp.org/license.txt.

Design rules: https://design.freecodecamp.org/handbook.md

