Skip to content
design
Components

Skeleton

A shimmering placeholder where content is loading.

On this page
AIS targets within 12 NMLoading targets from the AIS receiver
The AIS target list loading in round, block and line placeholders at the size of the rows that replace them.
Code
import { AisTargetRow, type AisShipType } from "@bosunkit/components/ais-target-row";
import { Skeleton } from "@bosunkit/components/skeleton";
import { Radar } from "lucide-react";
import { useEffect, useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "The AIS target list while it loads and after: round, block and text-line placeholders at the size of the rows " +
  "they hold, a spoken loading status, and the real target rows replacing them on a repeating cycle.";

const LOADING_MILLISECONDS: number = 3000;
const LOADED_MILLISECONDS: number = 6000;

interface Target {
  readonly mmsi: string;
  readonly name: string;
  readonly shipType: AisShipType;
  readonly cpa: number | null;
  readonly tcpa: number | null;
  readonly sog: number;
  readonly cog: number | null;
  readonly ageSeconds: number;
}

const TARGETS: readonly Target[] = [
  { mmsi: "228112233", name: "MARIA K", shipType: "cargo", cpa: 1.4, tcpa: 26, sog: 11.2, cog: 95, ageSeconds: 6 },
  { mmsi: "227445566", name: "AR VRO", shipType: "fishing", cpa: null, tcpa: null, sog: 0, cog: null, ageSeconds: 40 },
  { mmsi: "227778899", name: "PEN DUICK", shipType: "sailing", cpa: 3.1, tcpa: -4, sog: 6.4, cog: 210, ageSeconds: 18 },
];

/** The name placeholders' widths, so each row looks like the line it will hold rather than a uniform bar. */
const NAME_WIDTHS: readonly string[] = ["7em", "5em", "8em"];

const LIST: CSSProperties = { margin: 0, padding: 0, listStyle: "none" };

/**
 * A placeholder row at the size of a target row: the type icon, the name and type lines, and the figures, whose block
 * narrows on a phone rather than pushing the row past the screen's edge.
 */
const PLACEHOLDER: CSSProperties = {
  display: "grid",
  gridTemplateColumns: "2em minmax(9em, 1fr) minmax(0, 16em)",
  alignItems: "center",
  columnGap: "var(--space-md)",
  minHeight: "var(--target-touch-size)",
  padding: "var(--space-sm) var(--space-md)",
  borderBottom: "1px solid var(--border)",
  borderInlineStart: "0.25em solid transparent",
};

export default function Showcase(): ReactElement {
  const [loading, setLoading]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(true);

  // Loading for 3 s, loaded for 6 s, and round again, so the swap from placeholder to row can be watched.
  useEffect((): (() => void) => {
    const timer: number = window.setTimeout(
      (): void => {
        setLoading(!loading);
      },
      loading ? LOADING_MILLISECONDS : LOADED_MILLISECONDS,
    );
    return (): void => {
      window.clearTimeout(timer);
    };
  }, [loading]);

  return (
    <div style={{ width: "100%", maxWidth: 640 }}>
      <div style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)", marginBottom: "var(--space-sm)" }}>
        <Radar size={16} aria-hidden="true" />
        <strong style={{ fontFamily: "var(--font-mono)", fontSize: 13 }}>AIS targets within 12 NM</strong>
        <span role="status" style={{ marginInlineStart: "auto", fontSize: 12, color: "var(--text-dim)" }}>
          {loading ? "Loading targets from the AIS receiver" : `${String(TARGETS.length)} targets`}
        </span>
      </div>

      {loading ? (
        <Skeleton height={72} />
      ) : (
        <div
          style={{
            height: 72,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            border: "1px dashed var(--border)",
            borderRadius: "var(--radius-md)",
            fontFamily: "var(--font-mono)",
            fontSize: 12,
            color: "var(--text-dim)",
          }}
        >
          Nearest: AR VRO, 2.1 NM south
        </div>
      )}

      <ul style={LIST}>
        {TARGETS.map((target: Target, index: number): ReactElement =>
          loading ? (
            <li key={target.mmsi} style={PLACEHOLDER}>
              <Skeleton width="1.75em" height="1.75em" rounded />
              <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                <Skeleton width={NAME_WIDTHS[index] ?? "7em"} height={13} />
                <Skeleton width="9em" height={11} />
              </div>
              <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 6 }}>
                <Skeleton width="100%" height={26} />
                <Skeleton width="4em" height={11} />
              </div>
            </li>
          ) : (
            <AisTargetRow
              key={target.mmsi}
              name={target.name}
              mmsi={target.mmsi}
              shipType={target.shipType}
              cpa={target.cpa}
              tcpa={target.tcpa}
              sog={target.sog}
              cog={target.cog}
              cpaLimit={0.5}
              tcpaLimit={12}
              ageSeconds={target.ageSeconds}
            />
          ),
        )}
      </ul>
    </div>
  );
}

A skeleton holds the place of content that has not arrived, at the size the content will have, so the layout does not jump when the content appears. It shimmers while it waits, and the shimmer stops for a crew that has asked for reduced motion. A skeleton is hidden from assistive technology, so the loading state needs its own announcement where the crew relies on one. Size it with width and height, as pixels or any CSS length.

Examples

Block

A shimmering block holds the place of a line of content that is loading.
Code
import { Skeleton } from "@bosunkit/components/skeleton";
import type { ReactElement } from "react";

export const description: string = "A shimmering block holds the place of a line of content that is loading.";

export default function Block(): ReactElement {
  return <Skeleton width={200} height={24} />;
}

Round

rounded makes the placeholder a circle, for an avatar or a dot.

A round placeholder holds the place of an avatar or a dot.
Code
import { Skeleton } from "@bosunkit/components/skeleton";
import type { ReactElement } from "react";

export const description: string = "A round placeholder holds the place of an avatar or a dot.";

export default function Round(): ReactElement {
  return <Skeleton rounded width={32} height={32} />;
}

Motion

By default, inside a MotionPresence, a placeholder fades out as its content fades in, so the reading takes its place without a jump; one there on the first render does not move. motion takes the kit's motion prop: true fades outside a presence too, and false turns it off. It only ever fades, under reduced motion as without it.

A placeholder that fades out as the reading it stood for fades in, in the same place, so nothing jumps.
Code
import { Button } from "@bosunkit/components/button";
import { MotionPresence } from "@bosunkit/components/motion";
import { Reading } from "@bosunkit/components/reading";
import { Skeleton } from "@bosunkit/components/skeleton";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A placeholder that fades out as the reading it stood for fades in, in the same place, so nothing jumps.";

export default function Motion(): ReactElement {
  const [loaded, setLoaded]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  return (
    <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: "var(--space-md)" }}>
      <Button
        size="sm"
        onClick={(): void => {
          setLoaded((current: boolean): boolean => !current);
        }}
      >
        {loaded ? "Load again" : "The reading arrives"}
      </Button>
      {/* One grid cell for both, so the placeholder and the reading cross-fade in the same place. */}
      <div style={{ display: "grid", alignItems: "start", minHeight: 64 }}>
        <MotionPresence initial={false}>
          {loaded ? (
            <div key="reading" style={{ gridArea: "1 / 1" }}>
              <Reading value={6.2} unit="m" label="Depth" source="DST810" fractionDigits={1} />
            </div>
          ) : (
            <div key="skeleton" style={{ gridArea: "1 / 1" }}>
              <Skeleton width={120} height={48} />
            </div>
          )}
        </MotionPresence>
      </div>
    </div>
  );
}

Choose a loading placeholder deliberately

Match the expected content's main regions and avoid suggesting real text or data values. Use a separate loading announcement on the containing region when the wait matters. A skeleton has no retry or timeout behavior; remove it for a successful result and replace it with an explained failure when the request fails.

Do not replace a stale instrument reading with a skeleton. Preserve the last known value and its integrity where available. During a report refresh, keep the period and known content stable so placeholders do not imply that verified results have disappeared.

Props

PropTypeDefaultDescription
widthnumber | string | undefined"100%"The placeholder's width: pixels, or any CSS length.
heightnumber | string | undefined"var(--bk-type-body-reading-size, 1rem)"The placeholder's height: pixels, or any CSS length. A reading line's text size unless set: 16 px at the default size class, and larger in a helm scope.
roundedboolean | undefinedfalseBorder radius — "round" for circles/avatars.
classNamestring | undefined–Classes added to the placeholder's own.
motionMotionProp | undefined–How the placeholder moves. By default, inside a MotionPresence, it fades in and fades out as its content takes its place, so nothing jumps; one there on the first render does not move. true fades it outside a presence too; a preset or { enter, exit } chooses; false turns off the fades and the shimmer.
testIdstring | undefined–Becomes data-testid on the placeholder.
Do
Draw a skeleton for each line that will arrive, at its size, so nothing moves when the content lands.
Don't
Don't hold the place with one block of a guessed size. The rows that replace it move everything under them.
  • Motion: the shimmer and the reduced-motion rule.
  • Data integrity: what a reading shows when its data is missing.