Components

Stat

A stat is one number with its label: a total, a count, a variation. It is the answer to “how many?”, where ScoreBar answers “how good?”. The package ships the class strings; the figure, the dash and every word come from the app.

Import

import { stat } from '@sportingscouter/ui';

See ui/src/primitives/stat.ts for the slots, variants and the reasoning behind each decision.

Anatomy

Average rating
4.7
Up+0.2
Rating vs 2025
SlotWhat it is
groupOne plane for the whole row, with hairlines between the cells.
rootOne cell: a raised surface with the density’s padding.
labelThe eyebrow voice: uppercase by CSS, never in the string, text-muted.
valueThe figure, tabular, in ink. Three sizes: sm, md (default), lg.
deltaThe signed variation: an arrow, a spoken direction and the figure. The figure is always ink.
deltaIconThe arrow, an icon the app passes. Only this slot takes the tone: positive, negative or neutral.
deltaLabelThe spoken direction (“Up”, “Down”, “No change”), visually hidden, in the app’s language.
noteContext for the figure, such as the period or what it is compared with.
messageThe error text, in ink. Only in the error state.
actionThe recovery action under the message, such as “Try again”. Only in the error state.
skeletonValueThe loading placeholder for the figure: one value line tall, so the cell does not shift when the figure arrives.
skeletonNoteThe loading placeholder for the note.

A row

Several organisations: four stats, columns: 4. One column on a phone, two from sm, four from lg.

Companies
8
Events
9
Editions
14
Verified reviews
2,543

Two stats, columns: 2: one column on a phone, two from sm.

Editions
2
Verified reviews
640

One organisation: three stats, columns: 3. One column until lg, so three stats never leave a filler cell.

Events
1
Editions
2
Verified reviews
640

Alone, in a narrow cell: a long label wraps instead of overflowing.

Days to race day
21

Sizes

sm
2,543
md (default)
2,543
lg
2,543

The largest step is text-h1. text-display is marketing only and never a stat.

Delta: direction is not tone

The arrow follows the sign of the rounded delta, from deltaSign(). The tone is the app’s call: up is good for a rating and bad for disputes.

Rating
4.7
Up+0.2
Up, positive
Rating
4.5
Down-0.2
Down, negative
Days to reply
3.2
Up+0.4
Up, negative
Verified reviews
640
No change0.0
Flat, neutral

States

Ready
2,543
Verified reviews
Loading
Empty
–
Not enough reviews yet
Error
–
Could not load this number.
Try again

Rules

  • columns is the number of stats in the row. The hairlines are a 1px gap over a bg-border ground, so they stay uniform at any width. An odd count in a two-column layout would leave one grey cell. Three stats use columns: 3; two use columns: 2; five or more are two rows, so two groups.
  • A missing number is a dash, never 0. Empty and error both show the app’s dash; they differ by content: error also carries a message and a retry action. Loading shows a skeleton and never a stale figure.
  • Small text is ink. The delta figure and the error message do not use a status colour: those tokens do not meet AA on the dark ground. The tone colours only the arrow, and direction is also in the sign, the arrow’s shape and the spoken deltaLabel.
  • Direction and tone are separate. There is no direction variant: the arrow is an icon the app passes, from deltaSign().
  • Tier, colour and bar width come from roundRating(value), never from the raw value. When a stat shows a rating next to a ScoreBar or a tier, derive both from roundRating so the figure and the colour cannot disagree at a boundary: 3.45 is shown as 3.5, which is very-good.
  • A row is a <dl>. Each cell is a <div> with a <dt> and one or more <dd>, so a screen reader says “Verified reviews, 2,543”. A lone stat can be a plain <div>.
  • No copy in the package. The label, the dash, the spoken direction and the retry text are the app’s, in the app’s locale.

Structure snippet

import { stat, formatCount, formatDelta, deltaSign } from '@sportingscouter/ui';
import { ArrowUp, ArrowDown, Minus } from 'lucide-react';

const s = stat();
const locale = 'pt-PT'; // from the app

/* The arrow and the spoken direction come from the sign of the rounded delta.
   The tone is the app's call (up is good for a rating, bad for disputes), so it
   is an input and never derived from the sign. */
const DIRECTION = {
  up: { Icon: ArrowUp, label: 'Up' },
  down: { Icon: ArrowDown, label: 'Down' },
  flat: { Icon: Minus, label: 'No change' },
};

function VerifiedReviews({ count, change, tone }) { // tone is positive, negative or neutral
  const shown = formatCount(count, locale);          // "2 543", or null
  const text = formatDelta(change, locale);          // "+0,2", or null
  const sign = deltaSign(change);                    // 'up' | 'down' | 'flat' | null
  const direction = sign && DIRECTION[sign];

  return (
    <dl className={s.group({ columns: 1 })}>
      <div className={s.root()}>
        <dt className={s.label()}>Verified reviews</dt>
        <dd className={s.value({ state: shown == null ? 'empty' : 'ready' })}>
          {shown ?? '–'}
        </dd>
        {direction && text && (
          <dd className={s.delta()}>
            <direction.Icon className={s.deltaIcon({ tone })} aria-hidden="true" />
            <span className={s.deltaLabel()}>{direction.label}</span>{text}
          </dd>
        )}
        <dd className={s.note()}>Rating vs 2025</dd>
      </div>
    </dl>
  );
}

// loading: s.root() with aria-busy, and <span className={s.skeletonValue()} />
// error:   s.value({ state: 'error' }) + s.message() + s.action()
Sporting Scouter Design System
Every token on this site is generated from theme.css.