Mikkel Manniche
Dansk English Deutsch

Template · React · HTML

Store dashboard

Shop admin with a sidebar, KPI cards, a revenue chart with a period switch, top products and recent orders.

Preview

React

Install the template with shadcn. It lands in your project as ordinary components that you edit yourself.

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/store-dashboard.json
npx shadcn@latest add @manniche/store-dashboard
store-dashboard.tsx
// An admin dashboard for a small online shop. The period switch, the chart tooltips and the mobile menu are plain HTML
// and CSS (radio buttons with :has(), :hover, :focus-within and details), so the markup also works as a static page.
import {
  ArrowDownRight,
  ArrowUpRight,
  BarChart3,
  Bell,
  Box,
  ChevronDown,
  Home,
  Menu,
  Package,
  Search,
  Settings,
  ShoppingCart,
  Tag,
  Users,
  X,
} from 'lucide-react'
import { cn } from '@/lib/utils'

const navItems = [
  { icon: Home, label: 'Overview', current: true },
  { icon: ShoppingCart, label: 'Orders', count: 12 },
  { icon: Package, label: 'Products' },
  { icon: Users, label: 'Customers' },
  { icon: Tag, label: 'Discounts' },
  { icon: BarChart3, label: 'Reports' },
]

const kpis = [
  { label: 'Revenue', value: '€48,290', change: 12.4, points: [8, 10, 9, 12, 11, 14, 13, 16, 15, 18, 17, 21] },
  { label: 'Orders', value: '1,284', change: 8.1, points: [10, 11, 10, 12, 14, 13, 15, 14, 16, 15, 17, 18] },
  { label: 'Average order', value: '€37.60', change: 3.9, points: [12, 12, 13, 12, 13, 14, 13, 14, 14, 15, 14, 15] },
  { label: 'Returns', value: '2.1%', change: -0.6, points: [16, 15, 15, 14, 15, 13, 13, 12, 12, 11, 12, 10], lowerIsBetter: true },
]

const periods = [
  {
    id: 'week',
    label: '7 days',
    total: '€11,940',
    bars: [
      ['Mon', 1420],
      ['Tue', 1610],
      ['Wed', 1380],
      ['Thu', 1890],
      ['Fri', 2140],
      ['Sat', 2010],
      ['Sun', 1490],
    ],
  },
  {
    id: 'month',
    label: '30 days',
    total: '€48,290',
    bars: [
      ['Wk 1', 10200],
      ['Wk 2', 11800],
      ['Wk 3', 12650],
      ['Wk 4', 13640],
    ],
  },
  {
    id: 'year',
    label: '12 months',
    total: '€512,400',
    bars: [
      ['Jan', 31000],
      ['Feb', 29500],
      ['Mar', 36200],
      ['Apr', 38900],
      ['May', 41000],
      ['Jun', 39800],
      ['Jul', 37400],
      ['Aug', 40100],
      ['Sep', 44600],
      ['Oct', 47300],
      ['Nov', 58200],
      ['Dec', 68400],
    ],
  },
] as const

const topProducts = [
  { name: 'Morning mug', sold: 342, revenue: '€8,208', share: 0.92 },
  { name: 'Linen napkins, set of 4', sold: 251, revenue: '€7,028', share: 0.74 },
  { name: 'Serving bowl', sold: 168, revenue: '€7,056', share: 0.61 },
  { name: 'Water carafe', sold: 133, revenue: '€4,788', share: 0.42 },
  { name: 'Tall vase', sold: 71, revenue: '€4,118', share: 0.28 },
]

type Status = 'Paid' | 'Shipped' | 'Pending' | 'Refunded'
const orders: { id: string; customer: string; date: string; items: number; total: string; status: Status }[] = [
  { id: '#3021', customer: 'Ana Ruiz', date: 'Today, 09:41', items: 3, total: '€96.00', status: 'Paid' },
  { id: '#3020', customer: 'Jonas Petersen', date: 'Today, 08:12', items: 1, total: '€42.00', status: 'Pending' },
  { id: '#3019', customer: 'Mei Lin', date: 'Yesterday', items: 6, total: '€214.50', status: 'Shipped' },
  { id: '#3018', customer: 'Tomás Novak', date: 'Yesterday', items: 2, total: '€60.00', status: 'Shipped' },
  { id: '#3017', customer: 'Sara Holm', date: '2 Oct', items: 1, total: '€24.00', status: 'Refunded' },
]

const statusStyle: Record<Status, string> = {
  Paid: 'bg-primary/12 text-primary',
  Shipped: 'bg-success/12 text-success',
  Pending: 'bg-muted text-muted-foreground',
  Refunded: 'bg-destructive/12 text-destructive',
}

function Sparkline({ points, good }: { points: readonly number[]; good: boolean }) {
  const max = Math.max(...points)
  const min = Math.min(...points)
  const xy = points.map((p, i) => `${(i / (points.length - 1)) * 100},${28 - ((p - min) / (max - min || 1)) * 24}`)
  return (
    <svg viewBox="0 0 100 30" preserveAspectRatio="none" className="h-10 w-full overflow-visible" aria-hidden>
      <path d={`M0,30 L${xy.join(' L')} L100,30 Z`} className={good ? 'fill-primary/10' : 'fill-destructive/10'} />
      <path
        d={`M${xy.join(' L')}`}
        fill="none"
        strokeWidth="2"
        vectorEffect="non-scaling-stroke"
        strokeLinejoin="round"
        className={good ? 'stroke-primary' : 'stroke-destructive'}
      />
    </svg>
  )
}

function Brand() {
  return (
    <a href="#top" className="flex items-center gap-2 font-semibold tracking-tight">
      <span className="grid size-8 place-items-center rounded-lg bg-primary text-primary-foreground">
        <Box className="size-4" aria-hidden />
      </span>
      Linden Admin
    </a>
  )
}

function Nav() {
  return (
    <ul className="space-y-1">
      {navItems.map(({ icon: Icon, label, current, count }) => (
        <li key={label}>
          <a
            href={`#${label.toLowerCase()}`}
            aria-current={current ? 'page' : undefined}
            className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-accent-foreground"
          >
            <Icon className="size-4" aria-hidden />
            {label}
            {count && <span className="ml-auto rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground tabular-nums">{count}</span>}
          </a>
        </li>
      ))}
    </ul>
  )
}

/** Shop admin dashboard: sidebar, KPI cards with sparklines, a revenue chart with a period switch, top products and recent orders. */
export function StoreDashboard() {
  return (
    <div id="top" className="group/dash min-h-dvh bg-muted/40 font-sans text-foreground antialiased lg:grid lg:grid-cols-[15rem_1fr]">
      <aside className="hidden border-r bg-card lg:sticky lg:top-0 lg:flex lg:h-dvh lg:flex-col">
        <div className="flex h-16 items-center px-5">
          <Brand />
        </div>
        <nav aria-label="Main" className="flex-1 px-3 py-4">
          <Nav />
        </nav>
        <div className="m-3 rounded-xl border bg-accent/50 p-4">
          <p className="text-sm font-medium">Black Friday in 52 days</p>
          <p className="mt-1 text-xs text-muted-foreground">Set up discounts early so you can test them.</p>
          <a href="#discounts" className="mt-3 inline-block text-xs font-medium text-primary underline-offset-4 hover:underline">
            Plan a discount
          </a>
        </div>
        <a href="#settings" className="flex items-center gap-3 border-t px-6 py-4 text-sm text-muted-foreground transition-colors hover:text-foreground">
          <Settings className="size-4" aria-hidden />
          Settings
        </a>
      </aside>

      <div className="min-w-0">
        <header className="sticky top-0 z-20 flex h-16 items-center gap-3 border-b bg-background/85 px-4 backdrop-blur sm:px-6">
          <details className="group/menu relative lg:hidden">
            <summary className="grid size-9 cursor-pointer list-none place-items-center rounded-lg border [&::-webkit-details-marker]:hidden">
              <Menu className="size-4 group-open/menu:hidden" aria-hidden />
              <X className="hidden size-4 group-open/menu:block" aria-hidden />
              <span className="sr-only">Menu</span>
            </summary>
            <nav aria-label="Main" className="absolute top-12 left-0 w-60 rounded-xl border bg-card p-2 shadow-lg">
              <Nav />
            </nav>
          </details>
          <div className="lg:hidden">
            <Brand />
          </div>
          <label className="ml-auto hidden h-9 w-72 items-center gap-2 rounded-lg border bg-card px-3 text-sm text-muted-foreground focus-within:ring-2 focus-within:ring-ring sm:flex lg:ml-0">
            <Search className="size-4" aria-hidden />
            <input
              type="search"
              placeholder="Search orders, products…"
              className="w-full bg-transparent text-foreground outline-none placeholder:text-muted-foreground"
            />
          </label>
          <div className="ml-auto flex items-center gap-2 sm:ml-0 lg:ml-auto">
            <button type="button" className="relative grid size-9 place-items-center rounded-lg border bg-card" aria-label="Notifications, 3 new">
              <Bell className="size-4" aria-hidden />
              <span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary ring-2 ring-card" />
            </button>
            <button type="button" className="flex items-center gap-2 rounded-lg py-1 pr-2 pl-1 transition-colors hover:bg-muted">
              <span className="grid size-7 place-items-center rounded-full bg-accent text-xs font-semibold text-accent-foreground">MK</span>
              <span className="hidden text-sm font-medium sm:block">Maja K.</span>
              <ChevronDown className="size-4 text-muted-foreground" aria-hidden />
            </button>
          </div>
        </header>

        <main className="mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
          <div className="flex flex-wrap items-end justify-between gap-4">
            <div>
              <p className="text-sm text-muted-foreground">Good morning, Maja</p>
              <h1 className="text-2xl font-semibold tracking-tight">Overview</h1>
            </div>
            <fieldset className="flex rounded-lg border bg-card p-1 text-sm">
              <legend className="sr-only">Period</legend>
              {periods.map((p) => (
                <label
                  key={p.id}
                  className="cursor-pointer rounded-md px-3 py-1.5 text-muted-foreground transition-colors duration-150 has-checked:bg-foreground has-checked:text-background has-focus-visible:ring-2 has-focus-visible:ring-ring"
                >
                  <input type="radio" name="period" value={p.id} defaultChecked={p.id === 'month'} className="sr-only" />
                  {p.label}
                </label>
              ))}
            </fieldset>
          </div>

          <ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
            {kpis.map((k) => {
              const good = k.lowerIsBetter ? k.change < 0 : k.change > 0
              const Arrow = k.change > 0 ? ArrowUpRight : ArrowDownRight
              return (
                <li key={k.label} className="rounded-2xl border bg-card p-5 transition-shadow duration-200 hover:shadow-md">
                  <div className="flex items-center justify-between">
                    <p className="text-sm text-muted-foreground">{k.label}</p>
                    <span
                      className={cn(
                        'inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums',
                        good ? 'bg-success/12 text-success' : 'bg-destructive/12 text-destructive',
                      )}
                    >
                      <Arrow className="size-3" aria-hidden />
                      {Math.abs(k.change)}%
                    </span>
                  </div>
                  <p className="mt-2 text-2xl font-semibold tracking-tight tabular-nums">{k.value}</p>
                  <div className="mt-3">
                    <Sparkline points={k.points} good={good} />
                  </div>
                  <p className="mt-1 text-xs text-muted-foreground">vs. the 30 days before</p>
                </li>
              )
            })}
          </ul>

          <div className="grid gap-6 xl:grid-cols-[1.6fr_1fr]">
            <section aria-labelledby="revenue" className="min-w-0 rounded-2xl border bg-card p-5 sm:p-6">
              {periods.map((p) => {
                const max = Math.max(...p.bars.map(([, v]) => v))
                return (
                  <div
                    key={p.id}
                    className={cn(
                      'hidden',
                      p.id === 'week' && 'group-has-[[value=week]:checked]/dash:block',
                      p.id === 'month' && 'group-has-[[value=month]:checked]/dash:block',
                      p.id === 'year' && 'group-has-[[value=year]:checked]/dash:block',
                    )}
                  >
                    <div className="flex items-baseline justify-between gap-4">
                      <div>
                        <h2 id={p.id === 'month' ? 'revenue' : undefined} className="text-sm text-muted-foreground">
                          Revenue, {p.label}
                        </h2>
                        <p className="mt-1 text-3xl font-semibold tracking-tight tabular-nums">{p.total}</p>
                      </div>
                      <p className="flex items-center gap-2 text-xs text-muted-foreground">
                        <span className="size-2.5 rounded-sm bg-primary" aria-hidden />
                        Sales incl. VAT
                      </p>
                    </div>
                    <ol className="mt-8 flex h-56 items-end gap-2 border-b sm:gap-3" aria-label={`Revenue by period, ${p.label}`}>
                      {p.bars.map(([label, value], i) => (
                        <li key={label} className="group/bar relative flex h-full flex-1 flex-col justify-end" tabIndex={0}>
                          <span
                            className={cn(
                              'pointer-events-none absolute z-10 scale-95 rounded-md bg-foreground px-2 py-1 text-xs whitespace-nowrap text-background opacity-0 shadow-lg transition-[opacity,scale] duration-150 group-hover/bar:scale-100 group-hover/bar:opacity-100 group-focus/bar:scale-100 group-focus/bar:opacity-100',
                              i === 0 ? 'left-0' : i === p.bars.length - 1 ? 'right-0' : 'left-1/2 -translate-x-1/2',
                            )}
                            style={{ bottom: `calc(${(value / max) * 100}% + 6px)` }}
                          >
                            {label}: €{value.toLocaleString('en-GB')}
                          </span>
                          <span
                            className="block origin-bottom rounded-t-md bg-primary/75 transition-[background-color,scale] duration-500 ease-out-quint group-hover/bar:bg-primary group-focus/bar:bg-primary starting:scale-y-0"
                            style={{ height: `${(value / max) * 100}%`, transitionDelay: `${i * 30}ms` }}
                          />
                        </li>
                      ))}
                    </ol>
                    <ol className="mt-2 flex gap-2 text-center text-xs text-muted-foreground sm:gap-3" aria-hidden>
                      {p.bars.map(([label]) => (
                        <li key={label} className="flex-1 truncate">
                          {label}
                        </li>
                      ))}
                    </ol>
                  </div>
                )
              })}
            </section>

            <section aria-labelledby="top-products" className="min-w-0 rounded-2xl border bg-card p-5 sm:p-6">
              <div className="flex items-center justify-between">
                <h2 id="top-products" className="font-semibold">
                  Top products
                </h2>
                <a href="#products" className="text-sm text-muted-foreground transition-colors hover:text-foreground">
                  See all
                </a>
              </div>
              <ol className="mt-5 space-y-4">
                {topProducts.map((p, i) => (
                  <li key={p.name}>
                    <div className="flex items-center gap-3 text-sm">
                      <span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-xs font-medium text-muted-foreground tabular-nums">
                        {i + 1}
                      </span>
                      <span className="min-w-0 flex-1 truncate font-medium">{p.name}</span>
                      <span className="text-muted-foreground tabular-nums">{p.sold} sold</span>
                      <span className="w-16 text-right font-medium tabular-nums">{p.revenue}</span>
                    </div>
                    <div className="mt-2 ml-10 h-1.5 overflow-hidden rounded-full bg-muted">
                      <div
                        className="h-full origin-left rounded-full bg-primary transition-[scale] duration-700 ease-out-quint starting:scale-x-0"
                        style={{ width: `${p.share * 100}%` }}
                      />
                    </div>
                  </li>
                ))}
              </ol>
            </section>
          </div>

          <section aria-labelledby="orders" className="overflow-hidden rounded-2xl border bg-card">
            <div className="flex items-center justify-between p-5 sm:px-6">
              <h2 id="orders" className="font-semibold">
                Recent orders
              </h2>
              <a href="#orders" className="rounded-lg border px-3 py-1.5 text-sm transition-colors hover:bg-muted">
                View all
              </a>
            </div>
            <div className="overflow-x-auto">
              <table className="w-full min-w-160 text-left text-sm whitespace-nowrap">
                <thead className="border-y bg-muted/50 text-xs text-muted-foreground">
                  <tr>
                    {['Order', 'Customer', 'Date', 'Items', 'Status', 'Total'].map((h) => (
                      <th key={h} scope="col" className={cn('px-6 py-3 font-medium', h === 'Total' && 'text-right')}>
                        {h}
                      </th>
                    ))}
                  </tr>
                </thead>
                <tbody className="divide-y">
                  {orders.map((o) => (
                    <tr key={o.id} className="transition-colors duration-150 hover:bg-muted/40">
                      <td className="px-6 py-4 font-medium tabular-nums">{o.id}</td>
                      <td className="px-6 py-4">
                        <span className="flex items-center gap-3">
                          <span className="grid size-7 place-items-center rounded-full bg-accent text-xs font-semibold text-accent-foreground">
                            {o.customer
                              .split(' ')
                              .map((w) => w[0])
                              .join('')}
                          </span>
                          {o.customer}
                        </span>
                      </td>
                      <td className="px-6 py-4 text-muted-foreground">{o.date}</td>
                      <td className="px-6 py-4 tabular-nums">{o.items}</td>
                      <td className="px-6 py-4">
                        <span className={cn('inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium', statusStyle[o.status])}>
                          <span className="size-1.5 rounded-full bg-current" aria-hidden />
                          {o.status}
                        </span>
                      </td>
                      <td className="px-6 py-4 text-right font-medium tabular-nums">{o.total}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </section>

          <p className="pb-2 text-center text-xs text-muted-foreground">Linden Admin is a template from Manniche UI. All figures are made up.</p>
        </main>
      </div>
    </div>
  )
}
store-dashboard-demo.tsx
import { StoreDashboard } from '@/components/store-dashboard'

export default function StoreDashboardDemo() {
  return <StoreDashboard />
}

HTML

The whole page as one file with all CSS built in and no JavaScript. Toggles, tabs and filters work with HTML and CSS alone.

Open in a new tab

The colour in your own app

If you already have shadcn set up, these variables give the same colour. Light in :root, dark in .dark.

:root {
  --primary: oklch(0.52 0.2 262);
  --primary-foreground: oklch(0.99 0 0);
  --accent: oklch(0.95 0.034 262);
  --accent-foreground: oklch(0.36 0.140 262);
  --ring: oklch(0.52 0.2 262);
}

.dark {
  --primary: oklch(0.74 0.150 262);
  --primary-foreground: oklch(0.18 0.024 262);
  --accent: oklch(0.29 0.050 262);
  --accent-foreground: oklch(0.9 0.050 262);
  --ring: oklch(0.74 0.150 262);
}

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/store-dashboard.json

Dependencies

Files

  • store-dashboard.tsx
  • store-dashboard.html

Manniche Lab · open source

Free tools for AI agents and web development.