Skip to content
v2.5.0
English
Русский
Documentation
All templates

Analytics

A period switch that drives KPI cards, traffic sources, conversion, an activity heatmap and top pages.

Code

docs/examples/templates/analytics.tsx
import { useId, useState, type CSSProperties } from 'react';
import {
  Badge,
  Card,
  DataGrid,
  DataGridCell,
  DataGridRow,
  DataGridRowGroup,
  Heading,
  Icon,
  MeterStack,
  MeterStackSegment,
  Progress,
  ProgressTrack,
  SegmentedControl,
  SegmentedControlIndicator,
  SegmentedControlItem,
  SegmentedControlItemInput,
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  Text,
} from '@vega-ui/react';
import { ArrowDown, ArrowUp } from '@vega-ui/icons';
import './analytics.css';

type Period = '7' | '30' | '90';

const periods: { value: Period, label: string }[] = [
  { value: '7', label: '7 days' },
  { value: '30', label: '30 days' },
  { value: '90', label: '90 days' },
];

const stats: Record<Period, { visitors: [number, number], views: [number, number], signups: [number, number], session: [number, number], goal: number }> = {
  7: { visitors: [12_480, 11_020], views: [38_920, 36_410], signups: [412, 436], session: [194, 181], goal: 500 },
  30: { visitors: [51_760, 47_300], views: [162_340, 151_900], signups: [1_684, 1_520], session: [188, 192], goal: 2_000 },
  90: { visitors: [148_200, 121_450], views: [471_800, 402_600], signups: [4_912, 3_870], session: [185, 176], goal: 6_000 },
};

const sources = [
  { id: 'search', label: 'Search', share: [0.42, 0.39, 0.37], color: 'var(--color-blue-500)' },
  { id: 'direct', label: 'Direct', share: [0.24, 0.26, 0.27], color: 'var(--color-purple-500)' },
  { id: 'social', label: 'Social', share: [0.16, 0.15, 0.17], color: 'var(--color-green-500)' },
  { id: 'referral', label: 'Referral', share: [0.11, 0.12, 0.11], color: 'var(--color-orange-500)' },
  { id: 'email', label: 'Email', share: [0.07, 0.08, 0.08], color: 'var(--color-gray-500)' },
];

const pages = [
  { path: '/pricing', share: 0.18, time: 142 },
  { path: '/', share: 0.16, time: 64 },
  { path: '/docs/getting-started', share: 0.11, time: 296 },
  { path: '/blog/shipping-faster-with-previews', share: 0.07, time: 341 },
  { path: '/templates', share: 0.06, time: 118 },
  { path: '/changelog', share: 0.04, time: 87 },
];

const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const weekdayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const hours = Array.from({ length: 12 }, (_, index) => index * 2);
const dayLoad = [1, 1.08, 1.05, 0.98, 0.86, 0.42, 0.36];
const hourLoad = [0.08, 0.04, 0.06, 0.3, 0.82, 1, 0.92, 0.88, 0.7, 0.48, 0.34, 0.18];
const levels = [8, 24, 44, 68, 92];

const number = new Intl.NumberFormat('en-US');
const percent = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 });
const signedPercent = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1, signDisplay: 'exceptZero' });

const formatDuration = (seconds: number) => `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;

const formatHour = (hour: number) => `${String(hour).padStart(2, '0')}:00`;

const getLevel = (value: number, max: number) => Math.min(levels.length - 1, Math.floor((value / max) * levels.length));

const tabular = { fontVariantNumeric: 'tabular-nums' } as const;
const nowrap = { whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' } as const;
const card = { alignItems: 'stretch', gap: 'var(--spacing-10)', minWidth: 0 } as const;

export default function Analytics() {
  const id = useId();
  const [period, setPeriod] = useState<Period>('30');

  const data = stats[period];
  const index = periods.findIndex((item) => item.value === period);
  const label = periods[index].label;

  const kpis = [
    { id: 'visitors', title: 'Visitors', value: number.format(data.visitors[0]), current: data.visitors[0], previous: data.visitors[1] },
    { id: 'views', title: 'Page views', value: number.format(data.views[0]), current: data.views[0], previous: data.views[1] },
    { id: 'signups', title: 'Sign-ups', value: number.format(data.signups[0]), current: data.signups[0], previous: data.signups[1] },
    { id: 'session', title: 'Avg. session', value: formatDuration(data.session[0]), current: data.session[0], previous: data.session[1] },
  ];

  const traffic = sources.map((source) => ({ ...source, visitors: Math.round(data.visitors[0] * source.share[index]) }));
  const trafficTotal = traffic.reduce((sum, source) => sum + source.visitors, 0);
  const conversion = data.signups[0] / data.visitors[0];
  const goalShare = Math.min(1, data.signups[0] / data.goal);

  const activity = weekdays.map((_, day) => hours.map((_, slot) => Math.round((data.visitors[0] / 84) * dayLoad[day] * hourLoad[slot] * 1.6)));
  const peak = Math.max(...activity.flat());

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%', maxWidth: 960, marginInline: 'auto' }}>
      <header style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--spacing-8) var(--spacing-12)' }}>
        <div style={{ display: 'grid', gap: 'var(--spacing-2)', flex: '1 1 320px' }}>
          <Heading as='h1' size={7} fontWeight={700}>Analytics</Heading>
          <Text size={3} style={{ color: 'var(--label-secondary)' }}>Traffic and sign-ups for northwind.io.</Text>
        </div>
        <SegmentedControl
          name={`${id}-period`}
          role='radiogroup'
          aria-label='Period'
          value={period}
          onChange={(event) => setPeriod(event.target.value as Period)}
        >
          {periods.map((item) => (
            <SegmentedControlItem key={item.value} value={item.value}>
              <SegmentedControlItemInput />
              {item.label}
            </SegmentedControlItem>
          ))}
          <SegmentedControlIndicator />
        </SegmentedControl>
      </header>

      <ul aria-label={`Key metrics, last ${label}`} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 180px), 1fr))', gap: 'var(--spacing-8)', margin: 0, padding: 0, listStyle: 'none' }}>
        {kpis.map((kpi) => {
          const delta = (kpi.current - kpi.previous) / kpi.previous;
          const up = delta >= 0;

          return (
            <Card key={kpi.id} asChild style={{ ...card, gap: 'var(--spacing-3)' }}>
              <li>
                <Text size={2} fontWeight={500} style={{ color: 'var(--label-secondary)' }}>{kpi.title}</Text>
                <Text size={7} fontWeight={700} style={nowrap}>{kpi.value}</Text>
                <span style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--spacing-2) var(--spacing-4)' }}>
                  <Badge size='sm' variant={up ? 'success' : 'error'} style={{ gap: 'var(--spacing-1)', ...tabular }}>
                    <Icon size='xs' aria-hidden='true'>{up ? <ArrowUp strokeWidth={2.5} /> : <ArrowDown strokeWidth={2.5} />}</Icon>
                    {signedPercent.format(delta)}
                  </Badge>
                  <Text size={2} style={{ color: 'var(--label-secondary)' }}>{`vs previous ${label}`}</Text>
                </span>
              </li>
            </Card>
          );
        })}
      </ul>

      <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'stretch', gap: 'var(--spacing-8)' }}>
        <Card style={{ ...card, flex: '999 1 420px' }} aria-labelledby={`${id}-sources`}>
          <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--spacing-2) var(--spacing-8)' }}>
            <Heading id={`${id}-sources`} as='h2' size={4} fontWeight={600}>Traffic sources</Heading>
            <Text size={2} style={{ color: 'var(--label-secondary)', ...tabular }}>{`${number.format(trafficTotal)} visitors`}</Text>
          </div>
          <MeterStack value={trafficTotal} max={trafficTotal} role='group' aria-labelledby={`${id}-sources`} size='lg' fullWidth>
            {traffic.map((source) => (
              <MeterStackSegment
                key={source.id}
                value={source.visitors}
                aria-label={source.label}
                aria-valuetext={`${number.format(source.visitors)} visitors, ${percent.format(source.visitors / trafficTotal)}`}
                style={{ '--meter-stack-segment-color': source.color } as CSSProperties}
              />
            ))}
          </MeterStack>
          <ul style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(min(100%, 160px), 1fr))', gap: 'var(--spacing-5) var(--spacing-12)', margin: 0, padding: 0, listStyle: 'none' }}>
            {traffic.map((source) => (
              <li key={source.id} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
                <span aria-hidden='true' style={{ flexShrink: 0, width: 8, height: 8, borderRadius: '50%', background: source.color }} />
                <Text size={2}>{source.label}</Text>
                <Text size={2} style={{ marginInlineStart: 'auto', color: 'var(--label-secondary)', ...tabular }}>{percent.format(source.visitors / trafficTotal)}</Text>
              </li>
            ))}
          </ul>
        </Card>

        <Card style={{ ...card, flex: '1 1 260px', justifyContent: 'space-between' }} aria-labelledby={`${id}-conversion`}>
          <div style={{ display: 'grid', gap: 'var(--spacing-1)' }}>
            <Heading id={`${id}-conversion`} as='h2' size={4} fontWeight={600}>Conversion</Heading>
            <Text size={2} style={{ color: 'var(--label-secondary)' }}>Visitors who started a trial.</Text>
          </div>
          <Text size={7} fontWeight={700} style={nowrap}>{percent.format(conversion)}</Text>
          <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
            <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--spacing-2) var(--spacing-8)' }}>
              <Text id={`${id}-goal`} size={2} fontWeight={500}>Trial goal</Text>
              <Text size={2} style={{ color: 'var(--label-secondary)', ...tabular }}>{`${number.format(data.signups[0])} of ${number.format(data.goal)}`}</Text>
            </div>
            <Progress
              value={Math.round(goalShare * 100)}
              valueText={`${number.format(data.signups[0])} of ${number.format(data.goal)} trials`}
              aria-labelledby={`${id}-goal`}
              size='sm'
              fullWidth
            >
              <ProgressTrack />
            </Progress>
          </div>
        </Card>
      </div>

      <Card style={card} aria-labelledby={`${id}-activity`}>
        <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--spacing-4) var(--spacing-8)' }}>
          <div style={{ display: 'grid', gap: 'var(--spacing-1)' }}>
            <Heading id={`${id}-activity`} as='h2' size={4} fontWeight={600}>Activity by weekday and hour</Heading>
            <Text size={2} style={{ color: 'var(--label-secondary)' }}>Average visitors per two-hour window, in UTC.</Text>
          </div>
          <span aria-hidden='true' style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-2)' }}>
            <Text size={1} style={{ color: 'var(--label-secondary)', marginInlineEnd: 'var(--spacing-2)' }}>Less</Text>
            {levels.map((level) => <span key={level} className='analyticsSwatch' style={{ '--level': `${level}%` } as CSSProperties} />)}
            <Text size={1} style={{ color: 'var(--label-secondary)', marginInlineStart: 'var(--spacing-2)' }}>More</Text>
          </span>
        </div>
        <DataGrid aria-labelledby={`${id}-activity`} defaultActive='1:1' className='analyticsHeatmap'>
          <DataGridRowGroup>
            <DataGridRow row={0}>
              <DataGridCell col={0} role='columnheader' excluded aria-label='Weekday' />
              {hours.map((hour, slot) => (
                <DataGridCell key={hour} col={slot + 1} role='columnheader' excluded aria-label={formatHour(hour)}>
                  {slot % 2 === 0 ? String(hour).padStart(2, '0') : ''}
                </DataGridCell>
              ))}
            </DataGridRow>
          </DataGridRowGroup>
          <DataGridRowGroup>
            {activity.map((row, day) => (
              <DataGridRow key={weekdays[day]} row={day + 1}>
                <DataGridCell col={0} role='rowheader' excluded aria-label={weekdayNames[day]}>{weekdays[day]}</DataGridCell>
                {row.map((value, slot) => (
                  <DataGridCell
                    key={hours[slot]}
                    col={slot + 1}
                    aria-label={`${weekdayNames[day]}, ${formatHour(hours[slot])} to ${formatHour(hours[slot] + 2)}: ${number.format(value)} visitors`}
                    title={`${weekdays[day]} ${formatHour(hours[slot])}–${formatHour(hours[slot] + 2)} · ${number.format(value)}`}
                    style={{ '--level': `${levels[getLevel(value, peak)]}%` } as CSSProperties}
                  />
                ))}
              </DataGridRow>
            ))}
          </DataGridRowGroup>
        </DataGrid>
      </Card>

      <section aria-labelledby={`${id}-pages`} style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        <Heading id={`${id}-pages`} as='h2' size={4} fontWeight={600}>Top pages</Heading>
        <div
          role='region'
          aria-labelledby={`${id}-pages`}
          tabIndex={0}
          style={{ minWidth: 0, overflowX: 'auto', border: '1px solid var(--separator-opaque)', borderRadius: 'var(--radius-4)', background: 'var(--surface-primary)' }}
        >
          <Table fullWidth edgePadded style={{ borderStyle: 'hidden' }}>
            <TableHead>
              <TableRow>
                <TableHeading scope='col' dataAlign='start'>Page</TableHeading>
                <TableHeading scope='col' dataAlign='end'>Views</TableHeading>
                <TableHeading scope='col' dataAlign='end'>Share</TableHeading>
                <TableHeading scope='col' dataAlign='end'>Avg. time</TableHeading>
              </TableRow>
            </TableHead>
            <TableBody>
              {pages.map((page) => (
                <TableRow key={page.path}>
                  <TableData dataAlign='start' style={{ overflowWrap: 'anywhere' }}>{page.path}</TableData>
                  <TableData dataAlign='end' style={nowrap}>{number.format(Math.round(data.views[0] * page.share))}</TableData>
                  <TableData dataAlign='end' style={{ ...nowrap, color: 'var(--label-secondary)' }}>{percent.format(page.share)}</TableData>
                  <TableData dataAlign='end' style={nowrap}>{formatDuration(page.time)}</TableData>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </div>
      </section>
    </div>
  );
}

Components used