Key figures

Show the few labelled figures that sum up a view, and how each changed.

BetaSince 1.0.0
On this page

Import

import {
  AnimatedNumber,
  Badge,
  Button,
  Card,
  Column,
  Grid,
  Row,
  Surface,
  Text,
} from '@envsemble/envui';

Usage

A key figure is a Column of three Texts.

  • The label, with variant="body-sm" and emphasis="muted".
  • The figure, with variant="headline" and fontVariantNumeric: 'tabular-nums', so it keeps its width.
  • A Row with the trend Badge and a muted description.

The Badge takes the change in words and an icon, such as TrendingUp or TrendingFlat, and its tone says whether it is good news: success, danger, or neutral for no change. A figure that changes while people look at it is an AnimatedNumber inside the figure's Text.

ProjectHealth.tsx
import { useState, type ReactNode } from 'react';
import {
  AnimatedNumber,
  Badge,
  Button,
  Column,
  Grid,
  Row,
  Surface,
  Text,
  type BadgeTone,
} from '@envsemble/envui';
import { IconAdd } from '@envsemble/envui/icons/Add';
import { IconTrendingFlat } from '@envsemble/envui/icons/TrendingFlat';
import { IconTrendingUp } from '@envsemble/envui/icons/TrendingUp';

/** A labelled figure, with how it changed in words and over what period. */
function KeyFigure({
  label,
  value,
  trend,
  description,
}: {
  label: string;
  value: ReactNode;
  trend?: { icon: ReactNode; label: string; tone: BadgeTone };
  description?: string;
}) {
  return (
    <Column gap={1}>
      <Text variant="body-sm" emphasis="muted">
        {label}
      </Text>
      {/* Tabular digits, so the figure keeps its width as it changes. */}
      <Text variant="headline" style={{ fontVariantNumeric: 'tabular-nums' }}>
        {value}
      </Text>
      {(trend || description) && (
        <Row wrap align="center" gap={2}>
          {trend && (
            <Badge tone={trend.tone} icon={trend.icon}>
              {trend.label}
            </Badge>
          )}
          {description && (
            <Text render={<span />} variant="body-sm" emphasis="muted">
              {description}
            </Text>
          )}
        </Row>
      )}
    </Column>
  );
}

/** Active projects a month ago, which the trend compares with. */
const lastMonth = 114;

export default function ProjectHealth() {
  const [projects, setProjects] = useState(128);
  const change = Math.round(((projects - lastMonth) / lastMonth) * 100);
  return (
    <Column gap={4}>
      {/* As many even columns as fit, each at least 11 rem wide; named, so it reads as a group. */}
      <Surface
        role="group"
        aria-label="Project health"
        render={
          <Grid
            name="key-figures"
            columns="repeat(auto-fit, minmax(min(100%, 11rem), 1fr))"
            gap={5}
          />
        }
      >
        <KeyFigure
          label="Active projects"
          value={<AnimatedNumber value={projects} />}
          trend={{ icon: <IconTrendingUp />, label: `+${change}%`, tone: 'success' }}
          description="Last 30 days"
        />
        <KeyFigure
          label="Tasks done"
          value="1,284"
          trend={{ icon: <IconTrendingUp />, label: '+86', tone: 'success' }}
          description="This week"
        />
        <KeyFigure
          label="Lead time"
          value="2.5 days"
          trend={{ icon: <IconTrendingFlat />, label: 'No change', tone: 'neutral' }}
          description="Median"
        />
      </Surface>
      <Row wrap align="center" gap={4}>
        <Button
          variant="secondary"
          startIcon={<IconAdd />}
          onClick={() => setProjects((count) => count + 1)}
        >
          Add a project
        </Button>
      </Row>
    </Column>
  );
}

On a pane or in a card

On a pane, the group's Surface takes background="muted", inside the pane's own background="default" Surface with its heading. A single figure sits in a Card.

Composition

The group is a Surface with role="group" and an aria-label, rendering a Grid through render. The Grid's columns are repeat(auto-fit, minmax(min(100%, 11rem), 1fr)): as many even columns as fit, each at least 11 rem wide, and one column below that.

<Surface
  role="group"
  aria-label="Project health"
  render={<Grid columns="repeat(auto-fit, minmax(min(100%, 11rem), 1fr))" gap={5} />}
>
  {/* One Column per figure */}
</Surface>

Accessibility

You provide

  • role="group" on the Surface that holds the figures, named by aria-label or aria-labelledby.
  • A trend in words that say which way, such as +12% or 2 fewer, never in color or an arrow alone.
  • Tabular digits on the figure, font-variant-numeric: tabular-nums, so it keeps its width as it changes; AnimatedNumber uses them.
  • A heading level for the group's own heading, where it has one, that fits the page; the figures themselves are not headings.
  • Figures formatted in the reader's language.

envUI provides

  • The Badge's icon hidden from assistive technology, so its words are what is read.

Changelog

1.0.0

Key figures are a pattern built from Surface, Grid, Text and Badge, in place of the Stat component, with Stat.Root and Stat.Group.