Skip to content
Star on GitHub
Charts and tablesTop pages
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Top pages

Most visited pages with share of traffic and a quick sort. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add top-pages
Top pages0 views, last 30 days
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add top-pages

React

$npx designme add top-pages --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

A ranked list of a site's most visited pages, each with its view count, a bar for its share and its change against the period before. A switch sorts the list by views or by change, and the rows slide to their new places. It fits a traffic card on an analytics or marketing dashboard.

  • When you want a quick read on which pages get the most traffic.
  • When people should spot the fastest growing or falling pages by sorting on change.
  • When a dashboard has room for a compact list of pages.
03

Usage

Import it and drive it from your own state.

Import

import { TopPages } from '@/components/designme/top-pages/TopPages';

Example

The demo above as a React component, 17 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: the totals roll up, the tracks fill in, and the switch re-sorts.
import { TopPages, type TopPagesPage } from './TopPages';

const PAGES: TopPagesPage[] = [
  { path: '/pricing', title: 'Pricing', views: 18420, change: 24 },
  { path: '/', title: 'Home', views: 15210, change: 3 },
  { path: '/components', title: 'Components', views: 9870, change: 41 },
  { path: '/docs/agent', title: 'Agent docs', views: 6340, change: -8 },
  { path: '/blog/soft-ui', title: 'Soft UI, the post', views: 4120, change: 112 },
  { path: '/changelog', title: 'Changelog', views: 2210, change: -14 }
];

export default function TopPagesDemo() {
  return <div style={{ width: '100%' }}><TopPages pages={PAGES} site="designme.dev" /></div>;
}
04

Props

Everything TopPages takes.

PropTypeWhat it does
pagesrequiredTopPagesPage[]
titlestringHeader text
periodstringAfter the total, like "last 30 days"
sitestringPut in front of each path in the row tips, like "designme.dev"
sortTopPagesSortHow the rows are ordered. Leave it out to let the switch change it
defaultSortTopPagesSort
onSortChange(sort: TopPagesSort) => void
classNamestring
05

Code

The full source, as the CLI installs it.

TopPages.tsxReact · 91 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useReducedMotion, useTween } from '../lib/hooks';
import { Seg, Tip } from '../lib/motion';
import '../designme.css';
import './top-pages.css';

export interface TopPagesPage {
  /** The page's path, like "/pricing" */
  path: string;
  /** Its name, like "Pricing" */
  title: string;
  /** Views over the period */
  views: number;
  /** Change against the period before, in percent, like 24 or -8 */
  change: number;
  /** Shown when you hover the row. Defaults to the site and the path, like "designme.dev/pricing" */
  tip?: string;
}

export type TopPagesSort = 'views' | 'change';

export interface TopPagesProps {
  pages: TopPagesPage[];
  /** Header text */
  title?: string;
  /** After the total, like "last 30 days" */
  period?: string;
  /** Put in front of each path in the row tips, like "designme.dev" */
  site?: string;
  /** How the rows are ordered. Leave it out to let the switch change it */
  sort?: TopPagesSort;
  defaultSort?: TopPagesSort;
  onSortChange?: (sort: TopPagesSort) => void;
  className?: string;
}

const ROW = 50;
const fmt = (n: number) => Math.round(n).toLocaleString('en-US');

/** The most visited pages, with their views and change. Rows slide into place when the order changes. */
export function TopPages({ pages, title = 'Top pages', period = 'last 30 days', site, sort, defaultSort = 'views', onSortChange, className }: TopPagesProps) {
  const [by, setBy] = useControllable(sort, defaultSort, onSortChange);
  const reduce = useReducedMotion();
  const total = useTween(pages.reduce((a, p) => a + p.views, 0), 1000, 0);
  const max = Math.max(1, ...pages.map(p => p.views));
  const order = [...pages].sort((a, b) => by === 'views' ? b.views - a.views : b.change - a.change);

  // each track fills in a little after the one above it
  const [filled, setFilled] = useState(0);
  useEffect(() => {
    const ts = pages.map((_, i) => setTimeout(() => setFilled(n => Math.max(n, i + 1)), 200 + i * 70));
    return () => ts.forEach(clearTimeout);
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // the rows fade in from a blur, one after another
  const list = useRef<HTMLDivElement>(null);
  useEffect(() => {
    if (reduce || !list.current) return;
    [...list.current.children].forEach((row, i) => row.animate([{ opacity: 0, filter: 'blur(4px)' }, { opacity: 1, filter: 'blur(0)' }], { duration: 450, delay: i * 50, easing: 'ease-out', fill: 'backwards' }));
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  return (
    <div data-dm="" className={cx('dm-tpx dm-panel', className)}>
      <div className="dm-tpx-h">
        <div><b>{title}</b><span className="dm-muted dm-tpx-sub"><span className="dm-num dm-tpx-tot">{fmt(total)}</span> views, {period}</span></div>
        <Seg className="dm-tpx-sg" label="Sort" value={by} onChange={setBy} options={[{ value: 'views', label: 'Views' }, { value: 'change', label: 'Change' }]} />
      </div>
      <div className="dm-tpx-list" ref={list} style={{ height: pages.length * ROW }}>
        {pages.map((p, i) => {
          const at = order.indexOf(p);
          return (
            <Tip key={p.path} label={p.tip ?? (site ? site + p.path : p.path)}>
              <div className="dm-tpx-r" style={{ transform: `translateY(${at * ROW}px)` }}>
                <span className="dm-rk">{at + 1}</span>
                <span className="dm-pg">
                  <span className="dm-p">{p.title} <span className="dm-muted">{p.path}</span></span>
                  <span className="dm-tr"><i style={{ width: i < filled ? p.views / max * 100 + '%' : 0 }} /></span>
                </span>
                <span className="dm-n dm-num">{fmt(p.views)}</span>
                <span className={cx('dm-tag dm-ch dm-num', p.change >= 0 ? 'dm-ok' : 'dm-bad')}>{p.change >= 0 ? '+' : '−'}{Math.abs(p.change)}%</span>
              </div>
            </Tip>
          );
        })}
      </div>
    </div>
  );
}
top-pages.cssReact · 21 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Top pages ---------- */
.dm-tpx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:10px}
.dm-tpx-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:0 2px}
.dm-tpx-h>div:first-child{display:flex;flex-direction:column;font-size:13.5px}
.dm-tpx-h b{font-weight:500}
.dm-tpx-sub{font-size:12px}
.dm-tpx-sg button{font-size:12px;height:26px;padding:0 9px}
.dm-tpx-list{position:relative;height:calc(6 * 50px)}
.dm-tpx-r{position:absolute;left:0;right:0;height:46px;display:grid;grid-template-columns:20px 1fr 56px 60px;align-items:start;gap:10px;padding:9px var(--dm-s-2) 0;border-radius:var(--dm-r-in);transition:transform .55s var(--dm-ease),background .2s}
@media (hover:hover) and (pointer:fine){.dm-tpx-r:hover{background:var(--dm-row)}}
.dm-tpx-r .dm-rk{font-family:var(--dm-mono);font-size:11px;line-height:19.5px;color:var(--dm-muted);text-align:center;transition:color .2s}
@media (hover:hover) and (pointer:fine){.dm-tpx-r:hover .dm-rk{color:var(--dm-fg)}}
.dm-tpx-r .dm-pg{display:flex;flex-direction:column;gap:5px;min-width:0}
.dm-tpx-r .dm-p{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-tpx-r .dm-tr{height:4px;border-radius:2px;background:var(--dm-chip);overflow:hidden}
@media (hover:hover) and (pointer:fine){.dm-tpx-r:hover .dm-tr{background:var(--dm-surface)}}
.dm-tpx-r .dm-tr i{display:block;height:100%;width:0;border-radius:2px;background:var(--dm-accent-mark,var(--dm-accent));transition:width .9s var(--dm-ease)}
.dm-tpx-r .dm-n{font-size:13px;font-weight:500;min-width:52px;text-align:right}
.dm-tpx-r .dm-ch{min-width:52px;justify-content:center}
top-pages.htmlHTML · 9 lines
<div class="card" data-c="top-pages">
  <div class="stage-in">
    <div class="tpx panel">
      <div class="tpx-h"><div><b>Top pages</b><span class="muted tpx-sub"><span class="num tpx-tot">0</span> views, last 30 days</span></div><div class="seg2 tpx-sg" role="group" aria-label="Sort"><button aria-pressed="true" data-v="v">Views</button><button aria-pressed="false" data-v="c">Change</button></div></div>
      <div class="tpx-list"></div>
    </div>
  </div>
</div>
top-pages.cssHTML · 20 lines
/* ---------- Top pages ---------- */
.tpx{padding:var(--s-4);display:flex;flex-direction:column;gap:10px}
.tpx-h{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:0 2px}
.tpx-h>div:first-child{display:flex;flex-direction:column;font-size:13.5px}
.tpx-h b{font-weight:500}
.tpx-sub{font-size:12px}
.tpx-sg button{font-size:12px;height:26px;padding:0 9px}
.tpx-list{position:relative;height:calc(6 * 50px)}
.tpx-r{position:absolute;left:0;right:0;height:46px;display:grid;grid-template-columns:20px 1fr 56px 60px;align-items:start;gap:10px;padding:9px var(--s-2) 0;border-radius:var(--r-in);transition:transform .55s var(--ease),background .2s}
@media (hover:hover) and (pointer:fine){.tpx-r:hover{background:var(--row)}}
.tpx-r .rk{font-family:var(--mono);font-size:11px;line-height:19.5px;color:var(--muted);text-align:center;transition:color .2s}
@media (hover:hover) and (pointer:fine){.tpx-r:hover .rk{color:var(--fg)}}
.tpx-r .pg{display:flex;flex-direction:column;gap:5px;min-width:0}
.tpx-r .p{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tpx-r .tr{height:4px;border-radius:2px;background:var(--chip);overflow:hidden}
@media (hover:hover) and (pointer:fine){.tpx-r:hover .tr{background:var(--surface)}}
.tpx-r .tr i{display:block;height:100%;width:0;border-radius:2px;background:var(--accent-mark,var(--accent));transition:width .9s var(--ease)}
.tpx-r .n{font-size:13px;font-weight:500;min-width:52px;text-align:right}
.tpx-r .ch{min-width:52px;justify-content:center}
top-pages.jsHTML · 16 lines
DM.def('top-pages', (r, c) => {
  const P = [['/pricing','Pricing',18420,24],['/','Home',15210,3],['/components','Components',9870,41],['/docs/agent','Agent docs',6340,-8],['/blog/soft-ui','Soft UI, the post',4120,112],['/changelog','Changelog',2210,-14]];
  const list = $('.tpx-list', r), mx = P[0][2], tot = P.reduce((a, p) => a + p[2], 0), H = 50;
  list.innerHTML = P.map(p => `<div class="tpx-r" data-tip="designme.dev${p[0]}"><span class="rk"></span><span class="pg"><span class="p">${p[1]} <span class="muted">${p[0]}</span></span><span class="tr"><i></i></span></span><span class="n num">${p[2].toLocaleString('en-US')}</span><span class="tag ch num ${p[3] >= 0 ? 'ok' : 'bad'}">${p[3] >= 0 ? '+' : '−'}${Math.abs(p[3])}%</span></div>`).join('');
  const rows = $$('.tpx-r', list);
  function sort(k){
    const order = [...P].sort((a, b) => k === 'v' ? b[2] - a[2] : b[3] - a[3]);
    rows.forEach((row, i) => { const j = order.indexOf(P[i]); row.style.transform = `translateY(${j * H}px)`; $('.rk', row).textContent = j + 1; });
  }
  sort('v'); rows.forEach((row, i) => row.animate([{ opacity:0, filter:'blur(4px)' }, { opacity:1, filter:'blur(0)' }], { duration:450, delay:i * 50, easing:'ease-out', fill:'backwards' }));
  rows.forEach((row, i) => c.later(300 + i * 70, () => $('.tr i', row).style.width = P[i][2] / mx * 100 + '%'));
  $('.tpx-tot', r).textContent = '0';
  c.later(260, () => tween(0, tot, 1000, v => $('.tpx-tot', r).textContent = Math.round(v).toLocaleString('en-US')));
  seg($('.tpx-sg', r), sort);
});
06

Questions

What people ask about it.

How do I show the full URL when someone hovers a row?
Pass site, like "designme.dev", and each row's tooltip reads the site followed by the path. Set tip on a page to use your own text for that row.
What does change mean?
It is the percent change against the period before, like 24 or -8. Positive values show a green tag with a plus sign and negative values a red tag.
Can I start the list sorted by change?
Yes. Set defaultSort to 'change', or control it with sort and onSortChange.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Line chartTwo series over time with a hover readout and toggles.
Bar chartWeekly agent runs, split by outcome, with a range switch.
Donut chartWhere the agent's time went, by kind of task.
Funnel chartSignup to paid, with the biggest drop called out by the agent.
Health scoreA design system score made of a few checks you can open.
Activity heatmapWhen the agent and the team were busy, by day and hour.
Stat cardsFour headline numbers with sparklines and a period switch.
Account tableCustomer accounts with health, plan and an agent note per row.