Charts and tablesTop pages
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callTop 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add top-pagesReact
npx designme add top-pages --reactThe 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.
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.
- Each row shows its rank, title, path, view count and a green or red change tag.
- A track under each title fills relative to the most viewed page, one row after another.
- The header shows the total views across all pages, counting up, followed by
period. - The Views and Change switch re-sorts the list, and rows slide to their new rank.
- Hovering a row shows a tooltip with the full address: the row's
tip, orsiteplus the path.
- The sort switch is a role="group" labelled "Sort", and its buttons use aria-pressed.
- The rows' fade-in is skipped and the total's count-up finishes at once when the user prefers reduced motion.
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>;
}
Props
Everything TopPages takes.
| Prop | Type | What it does |
|---|---|---|
pagesrequired | TopPagesPage[] | |
title | string | Header text |
period | string | After the total, like "last 30 days" |
site | string | Put in front of each path in the row tips, like "designme.dev" |
sort | TopPagesSort | How the rows are ordered. Leave it out to let the switch change it |
defaultSort | TopPagesSort | |
onSortChange | (sort: TopPagesSort) => void | |
className | string |
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);
});
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. Settipon a page to use your own text for that row. - What does
changemean? - 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
defaultSortto 'change', or control it withsortandonSortChange.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.