Dashboard

An app page with a collapsing sidebar, a top bar with a user menu, headline numbers, charts and a table of recent rows.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the block:

npx ermineui add dashboard
pnpm dlx ermineui add dashboard
yarn dlx ermineui add dashboard
bunx ermineui add dashboard

That copies 4 files into src/components/blocks/dashboard/, and the 10 components it is built from. Files you already have are kept.

Use

import { Dashboard, DashboardShell } from '@/components/blocks/dashboard'
<script setup lang="ts">
import { Dashboard, DashboardShell } from '@/components/blocks/dashboard'
import { RouterLink } from 'vue-router'
</script>

<template>
  <!-- The whole sample page, with your data. -->
  <Dashboard :navigation="navigation" :user="user" :revenue="revenue" :orders="orders" :link-as="RouterLink" current="/" @sign-out="signOut" />

  <!-- Or just the frame, around a page of your own. -->
  <DashboardShell title="Customers" :navigation="navigation" :user="user" current="/customers">
    <template #actions><Button size="sm">Add customer</Button></template>
    <CustomerTable />
  </DashboardShell>
</template>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
Dashboard.vuesrc/components/blocks/dashboard/Dashboard.vue
DashboardShell.vuesrc/components/blocks/dashboard/DashboardShell.vue
index.tssrc/components/blocks/dashboard/index.ts
sample-data.tssrc/components/blocks/dashboard/sample-data.ts

It is built from: Avatar, Badge, Button, Card, Chart, Data table, Dropdown menu, Icon, Sidebar, Stat. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

Dashboard.vue 200 lines
<script setup lang="ts">
import { computed } from 'vue'
import { Badge } from '../../ui/badge'
import { Button } from '../../ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'
import { BarList, Chart } from '../../ui/chart'
import { DataTable, type DataTableColumn } from '../../ui/data-table'
import { Icon } from '../../ui/icon'
import { Stat } from '../../ui/stat'
import DashboardShell from './DashboardShell.vue'
import {
  sampleNavigation,
  sampleOrders,
  sampleProducts,
  sampleRevenue,
  sampleUser,
  type DashboardNavGroup,
  type DashboardOrder,
  type DashboardUser,
} from './sample-data'

// Everything shown comes in through these props. The defaults are made-up numbers from sample-data.ts.
const props = withDefaults(
  defineProps<{
    title?: string
    brand?: string
    navigation?: readonly DashboardNavGroup[]
    /** The href of the sidebar link for this page. */
    current?: string
    user?: DashboardUser
    /** One row per month: its name, the money taken and the money given back. */
    revenue?: ReadonlyArray<{ month: string; revenue: number; refunds: number }>
    /** Best-selling products and how many of each were sold. */
    products?: ReadonlyArray<{ product: string; sales: number }>
    orders?: readonly DashboardOrder[]
    /** Passed on to the shell: see DashboardShell.vue. */
    linkAs?: string | object
    mainAs?: string
    titleAs?: string
  }>(),
  {
    title: 'Overview',
    navigation: () => sampleNavigation,
    current: '#overview',
    user: () => sampleUser,
    revenue: () => sampleRevenue,
    products: () => sampleProducts,
    orders: () => sampleOrders,
  },
)

const emit = defineEmits<{
  signOut: []
  /** The "Export" button in the top bar was pressed. */
  export: []
}>()

// Fixed locale, so server and browser write the same text.
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
const cents = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const day = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })

const total = (key: 'revenue' | 'refunds') => props.revenue.reduce((sum, row) => sum + row[key], 0)
/** Change from the month before last to the last month, in percent. */
function change(key: 'revenue' | 'refunds'): number | undefined {
  const [before, last] = props.revenue.slice(-2)
  if (!before || !last || before[key] === 0) return undefined
  return Math.round(((last[key] - before[key]) / before[key]) * 1000) / 10
}

const paid = computed(() => props.orders.filter((order) => order.status === 'Paid'))
const averageOrder = computed(() => (paid.value.length ? paid.value.reduce((sum, order) => sum + order.total, 0) / paid.value.length : 0))

const revenueSeries = [
  { key: 'revenue', label: 'Revenue' },
  { key: 'refunds', label: 'Refunds' },
]

const orderColumns: DataTableColumn<DashboardOrder>[] = [
  { key: 'id', label: 'Order', sortable: true },
  { key: 'customer', label: 'Customer', sortable: true },
  { key: 'status', label: 'Status', sortable: true },
  { key: 'date', label: 'Date', sortable: true, format: (value) => day.format(new Date(`${value}T00:00:00Z`)) },
  { key: 'total', label: 'Total', sortable: true, align: 'right', format: (value) => cents.format(Number(value)) },
]

const statusVariant = { Paid: 'outline', Pending: 'secondary', Refunded: 'destructive' } as const
</script>

<template>
  <DashboardShell
    :title="title"
    :brand="brand"
    :navigation="navigation"
    :current="current"
    :user="user"
    :link-as="linkAs"
    :main-as="mainAs"
    :title-as="titleAs"
    @sign-out="emit('signOut')"
  >
    <template #actions>
      <Button variant="outline" size="sm" @click="emit('export')">
        <Icon name="download" />
        Export
      </Button>
    </template>

    <div class="er-dashboard-stats">
      <Card>
        <CardContent>
          <Stat label="Revenue" :value="money.format(total('revenue'))" :delta="change('revenue')" delta-label="vs last month" :trend="revenue.map((row) => row.revenue)" />
        </CardContent>
      </Card>
      <Card>
        <CardContent>
          <Stat label="Refunds" :value="money.format(total('refunds'))" :delta="change('refunds')" delta-label="vs last month" :up-is-good="false" />
        </CardContent>
      </Card>
      <Card>
        <CardContent><Stat label="Orders" :value="orders.length" /></CardContent>
      </Card>
      <Card>
        <CardContent><Stat label="Average paid order" :value="cents.format(averageOrder)" /></CardContent>
      </Card>
    </div>

    <div class="er-dashboard-split">
      <Card>
        <CardHeader>
          <CardTitle>Revenue</CardTitle>
          <CardDescription>Money taken and money given back, by month.</CardDescription>
        </CardHeader>
        <CardContent>
          <Chart type="area" :data="revenue" x="month" x-label="Month" :series="revenueSeries" :format="money.format" label="Revenue and refunds by month" />
        </CardContent>
      </Card>
      <Card>
        <CardHeader>
          <CardTitle>Top products</CardTitle>
          <CardDescription>Units sold this quarter.</CardDescription>
        </CardHeader>
        <CardContent>
          <BarList :data="products" name="product" value="sales" label="Top products by units sold" />
        </CardContent>
      </Card>
    </div>

    <Card>
      <CardHeader>
        <CardTitle>Recent orders</CardTitle>
        <CardDescription>Press a column heading to sort by it.</CardDescription>
      </CardHeader>
      <CardContent>
        <DataTable :rows="orders" :columns="orderColumns" row-key="id" :page-size="5" label="Recent orders">
          <template #cell-status="{ row }">
            <Badge :variant="statusVariant[row.status]">{{ row.status }}</Badge>
          </template>
        </DataTable>
      </CardContent>
    </Card>
  </DashboardShell>
</template>

<style>
@layer components {
  /* The layout follows the room the page area has, not the window, so it is right whether the sidebar is wide, narrow or hidden. */
  .er-dashboard-main {
    container-type: inline-size;
  }

  .er-dashboard-stats,
  .er-dashboard-split {
    display: grid;
    gap: 1rem;
  }

  /* Cards in a grid must be allowed to shrink, or a wide chart or table pushes the page sideways. */
  .er-dashboard-main .er-card {
    min-width: 0;
  }

  @container (min-width: 30rem) {
    .er-dashboard-stats {
      grid-template-columns: repeat(2, 1fr);
    }
  }

  @container (min-width: 60rem) {
    .er-dashboard-stats {
      grid-template-columns: repeat(4, 1fr);
    }

    .er-dashboard-split {
      grid-template-columns: 2fr 1fr;
    }
  }
}
</style>
DashboardShell.vue 240 lines
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Avatar } from '../../ui/avatar'
import { Button } from '../../ui/button'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '../../ui/dropdown-menu'
import { Icon } from '../../ui/icon'
import { Sidebar, SidebarGroup, SidebarItem } from '../../ui/sidebar'
import type { DashboardNavGroup, DashboardUser } from './sample-data'

const props = withDefaults(
  defineProps<{
    /** Name of the page, shown in the top bar. */
    title: string
    /** Name of the product, shown at the top of the sidebar. */
    brand?: string
    /** The links in the sidebar, in groups. */
    navigation: readonly DashboardNavGroup[]
    /** The href of the link for the page being shown. */
    current?: string
    /** Who is signed in. Leave out to hide the user menu. */
    user?: DashboardUser
    /** Tag or component for the sidebar links, e.g. RouterLink or NuxtLink. */
    linkAs?: string | object
    /** Tag for the page area. Change it only when the page already has a <main> around this block. */
    mainAs?: string
    /** Tag for the title. Change it only when the page already has an <h1>. */
    titleAs?: string
  }>(),
  { brand: 'Acme', linkAs: 'a', mainAs: 'main', titleAs: 'h1' },
)

const emit = defineEmits<{
  /** "Sign out" was chosen in the user menu. */
  signOut: []
}>()

/** Sidebar shrunk to icons. Only applies on a wide screen. */
const collapsed = defineModel<boolean>('collapsed', { default: false })
/** Sidebar slid in over the page. Only applies on a narrow screen. */
const open = ref(false)

const initials = computed(() =>
  (props.user?.name ?? '')
    .split(/\s+/)
    .map((word) => word[0] ?? '')
    .slice(0, 2)
    .join('')
    .toUpperCase(),
)

// One button does both jobs: on a narrow screen it slides the sidebar in, on a wide one it shrinks it.
// The width here is the one the sidebar itself switches at.
function toggleNavigation() {
  if (window.matchMedia('(max-width: 47.999rem)').matches) open.value = true
  else collapsed.value = !collapsed.value
}

// Router links take "to"; a plain link takes "href".
const linkTo = (href: string) => (props.linkAs === 'a' ? { href } : { to: href })
</script>

<template>
  <div class="er-dashboard">
    <Sidebar v-model:collapsed="collapsed" v-model:open="open" off-canvas label="Main">
      <template #header="{ collapsed: rail }">
        <div class="er-dashboard-brand">
          <slot name="brand" :collapsed="rail">
            <span class="er-dashboard-brand-mark" aria-hidden="true">{{ brand.slice(0, 1) }}</span>
            <span v-if="!rail" class="er-dashboard-brand-name">{{ brand }}</span>
          </slot>
        </div>
      </template>

      <SidebarGroup v-for="group in navigation" :key="group.label" :label="group.label">
        <SidebarItem v-for="item in group.items" :key="item.href" :as="linkAs" v-bind="linkTo(item.href)" :active="item.href === current">
          <template #icon><Icon :name="item.icon" /></template>
          {{ item.label }}
        </SidebarItem>
      </SidebarGroup>

      <template v-if="$slots['sidebar-footer']" #footer="{ collapsed: rail }">
        <slot name="sidebar-footer" :collapsed="rail" />
      </template>
    </Sidebar>

    <div class="er-dashboard-body">
      <header class="er-dashboard-bar">
        <Button variant="ghost" size="icon" aria-label="Toggle navigation" @click="toggleNavigation">
          <Icon name="panel-left" />
        </Button>
        <component :is="titleAs" class="er-dashboard-title">{{ title }}</component>

        <div class="er-dashboard-actions">
          <slot name="actions" />
          <DropdownMenu v-if="user">
            <DropdownMenuTrigger class="er-dashboard-user" :aria-label="`Account menu for ${user.name}`">
              <Avatar size="sm" :src="user.avatar">{{ initials }}</Avatar>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuLabel>
                {{ user.name }}
                <span class="er-dashboard-user-email">{{ user.email }}</span>
              </DropdownMenuLabel>
              <DropdownMenuSeparator />
              <slot name="user-menu" />
              <DropdownMenuItem @select="emit('signOut')">
                <Icon name="log-out" />
                Sign out
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
      </header>

      <component :is="mainAs" class="er-dashboard-main">
        <slot />
      </component>
    </div>
  </div>
</template>

<style>
@layer components {
  /* The sidebar stays put and the page area scrolls. Set --er-dashboard-height to fit the block into a smaller space. */
  .er-dashboard {
    display: flex;
    height: var(--er-dashboard-height, 100dvh);
    background: var(--er-background);
    color: var(--er-foreground);
    overflow: hidden;
  }

  .er-dashboard-brand {
    display: flex;
    align-items: center;
    gap: 0.625rem;
    height: 2.25rem;
    padding: 0 0.25rem;
    font-weight: var(--er-weight-semibold);
    white-space: nowrap;
    overflow: hidden;
  }

  .er-dashboard-brand-mark {
    display: grid;
    flex-shrink: 0;
    place-items: center;
    width: 1.75rem;
    height: 1.75rem;
    border-radius: var(--er-radius-md);
    background: var(--er-primary);
    color: var(--er-primary-foreground);
    font-size: 0.875rem;
  }

  .er-dashboard-body {
    display: flex;
    flex: 1;
    flex-direction: column;
    min-width: 0;
    overflow: hidden auto;
  }

  .er-dashboard-bar {
    position: sticky;
    top: 0;
    z-index: 1;
    display: flex;
    flex-shrink: 0;
    align-items: center;
    gap: 0.5rem;
    min-height: 3.3125rem;
    padding: 0.5rem 1rem 0.5rem 0.5rem;
    border-bottom: 1px solid var(--er-border);
    background: var(--er-background);
  }

  .er-dashboard-title {
    min-width: 0;
    margin: 0;
    font-size: 1rem;
    font-weight: var(--er-weight-semibold);
    line-height: 1.5rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .er-dashboard-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-left: auto;
  }

  .er-dashboard-user {
    display: grid;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: none;
    cursor: pointer;
  }

  .er-dashboard-user:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-dashboard-user-email {
    display: block;
    color: var(--er-muted-foreground);
    font-weight: 400;
  }

  .er-dashboard-main {
    display: grid;
    flex: 1;
    align-content: start;
    gap: 1rem;
    min-width: 0;
    padding: 1rem;
  }

  @media (min-width: 48rem) {
    .er-dashboard-main {
      gap: 1.5rem;
      padding: 1.5rem;
    }
  }
}
</style>
index.ts 5 lines
/** Dashboard — an app page with a collapsing sidebar, a top bar with a user menu, headline numbers, charts and a table of recent rows. */
export { default as Dashboard } from './Dashboard.vue'
export { default as DashboardShell } from './DashboardShell.vue'
export * from './sample-data'
sample-data.ts 86 lines
import type { IconName } from '../../ui/icon'

/**
 * Made-up numbers so the dashboard has something to show. Replace this file with your own data,
 * or pass your own through the props of Dashboard.vue and DashboardShell.vue.
 */

export interface DashboardNavItem {
  label: string
  href: string
  icon: IconName
}

export interface DashboardNavGroup {
  label: string
  items: DashboardNavItem[]
}

export interface DashboardUser {
  name: string
  email: string
  /** Address of a picture. The initials show when there is none. */
  avatar?: string
}

export interface DashboardOrder {
  id: string
  customer: string
  status: 'Paid' | 'Pending' | 'Refunded'
  date: string
  total: number
}

export const sampleNavigation: DashboardNavGroup[] = [
  {
    label: 'Shop',
    items: [
      { label: 'Overview', href: '#overview', icon: 'home' },
      { label: 'Orders', href: '#orders', icon: 'inbox' },
      { label: 'Products', href: '#products', icon: 'package' },
      { label: 'Customers', href: '#customers', icon: 'users' },
      { label: 'Reports', href: '#reports', icon: 'bar-chart' },
    ],
  },
  {
    label: 'Account',
    items: [
      { label: 'Billing', href: '#billing', icon: 'credit-card' },
      { label: 'Settings', href: '#settings', icon: 'settings' },
    ],
  },
]

export const sampleUser: DashboardUser = { name: 'Ines Kovač', email: 'ines@example.com' }

export const sampleRevenue = [
  { month: 'Jan', revenue: 18400, refunds: 620 },
  { month: 'Feb', revenue: 19750, refunds: 540 },
  { month: 'Mar', revenue: 22300, refunds: 810 },
  { month: 'Apr', revenue: 21100, refunds: 700 },
  { month: 'May', revenue: 24900, refunds: 660 },
  { month: 'Jun', revenue: 27600, refunds: 930 },
  { month: 'Jul', revenue: 26200, refunds: 720 },
  { month: 'Aug', revenue: 29800, refunds: 880 },
  { month: 'Sep', revenue: 31450, refunds: 760 },
]

export const sampleProducts = [
  { product: 'Linen throw', sales: 412 },
  { product: 'Oak side table', sales: 287 },
  { product: 'Ceramic planter', sales: 264 },
  { product: 'Wool rug, 2 m', sales: 151 },
  { product: 'Brass wall hook', sales: 98 },
]

export const sampleOrders: DashboardOrder[] = [
  { id: '#4821', customer: 'Tomás Ortega', status: 'Paid', date: '2026-09-28', total: 184.5 },
  { id: '#4820', customer: 'Priya Nair', status: 'Pending', date: '2026-09-28', total: 62 },
  { id: '#4819', customer: 'Jonas Weber', status: 'Paid', date: '2026-09-27', total: 310.25 },
  { id: '#4818', customer: 'Aiko Tanaka', status: 'Refunded', date: '2026-09-27', total: 48 },
  { id: '#4817', customer: 'Lucía Fernández', status: 'Paid', date: '2026-09-26', total: 129.9 },
  { id: '#4816', customer: 'Omar Haddad', status: 'Paid', date: '2026-09-26', total: 75.4 },
  { id: '#4815', customer: 'Hanna Lindqvist', status: 'Pending', date: '2026-09-25', total: 219 },
  { id: '#4814', customer: 'Kwame Mensah', status: 'Paid', date: '2026-09-25', total: 94.75 },
]