DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A loading skeleton is a second implementation of your layout, and mine was lying about four things

A route-level loading.tsx in Next.js is the cheapest good thing you can add to
a slow page. It is also a duplicate of your layout written in grey boxes, and
like every duplicate it drifts.

Here is the skeleton I had for our assessment centre exercise list. It looks
fine:

function ExerciseCardSkeleton() {
  return (
    <div className="border-border bg-card flex h-full flex-col gap-3 rounded-xl border p-5">
      <div className="flex items-start justify-between gap-3">
        <div className="flex min-w-0 items-center gap-3">
          <Skeleton className="h-11 w-11 shrink-0 rounded-xl" />
          <Skeleton className="h-5 w-32" />
        </div>
        <Skeleton className="h-5 w-16 shrink-0 rounded-full" />
      </div>
      <div className="flex-1 space-y-2">
        <Skeleton className="h-4 w-full" />
        <Skeleton className="h-4 w-3/4" />
      </div>
      <div className="flex flex-wrap gap-3">
        <Skeleton className="h-3 w-20" />
        <Skeleton className="h-3 w-12" />
        <Skeleton className="h-3 w-14" />
        <Skeleton className="h-3 w-20" />
      </div>
      <div className="border-border/60 mt-1 border-t pt-3">
        <Skeleton className="h-4 w-28" />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the card it is standing in for. An icon tile of h-14 w-14, not h-11
w-11
. A title that is two lines, not one. shadow-sm and no border. Padding of
px-3 py-6 sm:p-6, not a flat p-5. A meta row of a clock, a duration and a
sector label, which is three items, not four. And no footer divider at all.

Every one of those is a small lie, and the sum of them is a page that reflows
the moment the real content lands. That is a Cumulative Layout Shift you
authored on purpose and then forgot about.

The rule I settled on

A skeleton earns its place only if swapping it for the real component changes
nothing about the box model. So it stops being a drawing of a card and starts
being a measurement of one:

/** Mirrors ExerciseCard: icon tile beside a two-line title, description, then time and sector. */
function ExerciseCardSkeleton() {
  return (
    <div className="bg-card h-full rounded-xl shadow-sm">
      <div className="space-y-3 px-3 py-6 sm:p-6">
        <div className="flex items-start justify-between gap-3">
          <div className="flex min-w-0 flex-1 items-center gap-3">
            <Skeleton className="h-14 w-14 shrink-0 rounded-xl" />
            <div className="w-full space-y-2">
              <Skeleton className="h-5 w-3/4" />
              <Skeleton className="h-5 w-1/2" />
            </div>
          </div>
          <Skeleton className="h-5 w-16 shrink-0 rounded-full" />
        </div>
        <div className="space-y-2.5 py-0.5">
          <Skeleton className="h-4 w-full" />
          <Skeleton className="h-4 w-4/5" />
        </div>
      </div>
      <div className="px-3 py-3 sm:px-6 sm:py-4">
        <div className="flex h-5 items-center gap-2">
          <Skeleton className="h-4 w-4 rounded-full" />
          <Skeleton className="h-4 w-14" />
          <Skeleton className="h-4 w-32" />
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The two title bars at w-3/4 and w-1/2 are not decoration. The real title is
long enough to wrap to two lines on most cards, and if the skeleton shows one,
every card in the grid grows by 28px when the data arrives.

The description is line-clamp-2 in the real card, so the skeleton is allowed
to be exactly two bars. A clamp is the one case where you know the height in
advance, which makes it the easiest thing in the world to match and an
embarrassing thing to get wrong.

Text lines need a line box, not just a bar

The first thing I needed and did not have was a way to stand in for a line of
small text. A Skeleton with h-3.5 is 14px tall. A line of 14px text occupies
a 20px line box. Stack four of them and the skeleton is 24px shorter than the
paragraph, which is a quarter of a card.

So every stand-in for text is a bar inside a line box of the right height:

/**
 * One line of small body text: a 20px line box around a bar, so stacked lines
 * take the height the real text does. `phoneOnly` lines stand in for the extra
 * lines the text wraps onto on a narrow screen.
 */
function TextLine({ width, phoneOnly = false }: { width: string; phoneOnly?: boolean }) {
  return (
    <div className={`flex h-5 items-center${phoneOnly ? ' sm:hidden' : ''}`}>
      <Skeleton className={`h-3.5 ${width}`} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

h-5 is the line box, h-3.5 is the ink. The bar is centred in the box, so
stacked lines get the real leading between them for free.

The phoneOnly flag is the part I am actually pleased with

Here is the thing nobody tells you about skeletons: the real content wraps
differently at different widths, so a single skeleton cannot be correct at both.
A sentence that is one line on a laptop is four lines on a 320px phone. A
skeleton with one bar is then 60px short, which on a phone is most of the
viewport's worth of shift.

The fix is to let the skeleton wrap too, by declaring the extra lines and hiding
them above the breakpoint:

<div className="mt-0.5">
  <TextLine width="w-full max-w-xl" />
  <TextLine width="w-full" phoneOnly />
  <TextLine width="w-full" phoneOnly />
  <TextLine width="w-1/3" phoneOnly />
</div>
Enter fullscreen mode Exit fullscreen mode

One line from sm up, four lines below it, and the last of the four is a third
of the width because that is roughly where the sentence ends. The same trick
appears in the page description:

{/* The description wraps to three lines on a phone, one from sm up. */}
<div className="flex flex-col gap-2 py-1">
  <Skeleton className="h-5 w-full max-w-2xl" />
  <Skeleton className="h-5 w-full sm:hidden" />
  <Skeleton className="h-5 w-1/4 sm:hidden" />
</div>
Enter fullscreen mode Exit fullscreen mode

This is only maintainable because the comment says what it is claiming. "Wraps
to three lines on a phone, one from sm up" is a falsifiable statement about
the copy above it. If someone shortens that sentence, the comment is visibly
wrong, which is the best you can hope for from a duplicate.

Chips have to wrap in the same places

The sector filter is a row of pill buttons: "All sectors" plus one per sector,
with a count on each. They wrap. If the skeleton's pills are a different set of
widths, the row occupies a different number of lines and the whole grid below it
moves.

/** Approximate widths of the five sector chips, so the row wraps where the real one does. */
const SECTOR_CHIP_WIDTHS = ['w-[118px]', 'w-[118px]', 'w-[160px]', 'w-[186px]', 'w-[128px]'];
Enter fullscreen mode Exit fullscreen mode

Five hardcoded pixel widths, in order, measured off the real thing. Not elegant.
Correct, which beats elegant in a file whose entire job is to be the same shape
as another file.

Their height is the one number in here I can derive rather than measure:

<Skeleton key={i} className={`h-[30px] rounded-full ${width}`} />
Enter fullscreen mode Exit fullscreen mode

The real chip is text-sm inside px-3 py-1 with a 1px border. That is a 20px
line box, plus 4px of padding top and bottom, plus 1px of border top and bottom.
20 + 8 + 2 = 30. An h-[30px] with a comment pointing at the arithmetic is more
honest than py-1 on a Skeleton that has no text in it to make the line box.

The thing that triggered the rewrite

I added a UK/US spelling toggle to this page, which I wrote about
yesterday.
It sits at the right of the title line on wide screens and under the description
on narrow ones:

<div className="flex items-center gap-3 lg:shrink-0">
  <Skeleton className="h-4 w-14 shrink-0" />
  <Skeleton className="h-[54px] flex-1 rounded-full sm:h-[46px] sm:w-[214px] sm:flex-none" />
</div>
Enter fullscreen mode Exit fullscreen mode

Those two heights come out of the same arithmetic as the chips. The real
toggle's segments are 44px on phones and 36px from sm up, inside a p-1
wrapper with a 1px border: 44 + 8 + 2 = 54, and 36 + 8 + 2 = 46.

A new control meant the skeleton's header had to change, and once I was in the
file I noticed how little the rest of it resembled the page. That is the
practical lesson, such as it is: a loading.tsx has no tests, nothing imports
it, and nothing will ever tell you it has gone stale. It only gets looked at
when something else drags you into the file. So when something does, re-measure
the whole thing rather than the bit you came for.

Worth saying out loud

If your page does not shift, nobody notices any of this. If it does, people
notice without being able to say why, and they trust the page less. For a
product whose whole proposition is "this feels like the real assessment", a page
that jumps while it settles is the wrong first impression.

The dashboard this skeleton belongs to needs an account, but the public page it
mirrors is the
assessment centre practice hub,
where the free e-tray scenario runs in full without signing in. The same card
pattern, with screenshots on top, is what the
practice test library is built from.

Top comments (0)