Search pages, headings and code across the docs, examples and tutorials.

to navigateto open
Dashboard

Cards and tabs

Cards, tabs, and disclosures.

A card is the kit's surface: a bordered box that holds one thing on a page, with an optional header and footer. Tabs and disclosures fold more content into the same space and show one part at a time.

dashboard/pages/roster.ts
import { card, defineDashboardPage, page } from 'ohnejs/dashboard';

export default defineDashboardPage(() =>
  page(
    { title: 'Roster' },
    card('Thrall, Jaina Proudmoore, and Baine', { title: 'Raid roster', icon: 'users' }),
  ),
);
A card with a users icon and the title Raid roster over a list of names

The body comes first, the options second. A body is anything a page renders: text, an element, a table, a chart.

The header

title, icon, badge, and actions build the header:

ts
card(roster, {
  title: 'Raid roster',
  icon: 'users',
  badge: badge('4 / 25', { size: -2 }),
  actions: button('Invite', { icon: 'user-plus', variant: 'outline', size: -1 }),
})
A card header with an icon, a title, a count badge, and an Invite button at the end
  • title takes a message key or plain text.
  • icon names a Tabler icon, muted before the title.
  • header replaces the built header with your own content.
  • A card with none of them has no header at all.

View all

more adds a muted link at the end of the header, to the page that shows everything the card summarizes:

ts
card(recentRaids, { title: 'Recent raids', more: { href: '/raids' } })

Omitted, its label reads "View all". Name the destination when it says more: more: { href: '/bank', label: 'Open the bank' }.

href turns the whole card into one link, with a hover and a focus ring:

ts
card(summary, { title: 'Icecrown Citadel', href: '/raids/icecrown' })
  • The whole surface is the link, never just the title.
  • A linking card holds no other link or button, so its type refuses actions, more, collapsible, and header beside href.
  • A stat tile links the same way.

footer renders under the body, behind a separating border:

ts
card(settings.element, { title: 'Raid settings', footer: button('Save') })
A card with a muted footer line under its body

It usually holds a form's save button, or a muted line saying when the data last changed.

Flush

flush: true drops the body padding, so a table or a list runs edge to edge:

ts
card(lootList, { title: 'Loot', icon: 'trophy', flush: true })
A card whose list rows run to the card's edges

Danger zone

tone: 'destructive' paints the border and the title in the destructive color, for actions that cannot be undone:

ts
card(disband, { title: 'Danger zone', icon: 'alert-triangle', tone: 'destructive' })
A red-bordered Danger zone card with a Disband the guild button

Collapsible

collapsible adds a toggle to the header that folds the body and the footer away:

ts
const open = ref(true);

card(roster, { title: 'Raid roster', collapsible: { open } })
  • collapsible: true starts open and keeps its own state.
  • { open } binds a ref, so your code can fold the card and read whether it is open.

Size and failures

  • size from -2 to 2 scales the card and everything inside it. Omitted, the card takes the size of its surroundings.
  • A function body that throws while it builds shows an error state inside the card, and the rest of the page keeps working.

Tabs

tabs shows one panel at a time under a row of tabs:

ts
tabs([
  { name: 'roster', label: 'Roster', icon: 'users', content: () => roster() },
  { name: 'loot', label: 'Loot', icon: 'trophy', bubble: bubble('3'), content: () => loot() },
])
A row of three tabs with icons, two of them carrying count bubbles
  • content builds the panel each time its tab opens, and switching away disposes it. Keep state that must survive in a ref outside the panel.
  • bubble puts a count after the label.
  • The tab row shows only with more than one tab.

Controlling the tabs

active binds the open tab to a ref. With useSearchParam, the open tab survives a reload and a shared link:

ts
const view = useSearchParam('view', { fallback: 'roster', choices: ['roster', 'loot'] });

tabs(
  [
    { name: 'roster', label: 'Roster', content: () => roster() },
    { name: 'loot', label: 'Loot', content: () => loot() },
  ],
  { active: view },
)
  • The ref types the names, so a tab named outside its values fails the typecheck.
  • A value no tab carries shows the first tab.
  • Omitted, the tabs keep their own state and start on the first.

Disclosure

disclosure hides content behind a row that opens it:

ts
disclosure(() => h('p', null, 'Need before greed, always.'), {
  summary: 'What are the loot rules?',
  group: 'raid-faq',
})
Three question rows, each with a chevron that opens it
  • The content builds on the first open and stays mounted, so its state survives closing.
  • badge sits right after the summary, as it does after a card title.
  • open binds a ref. Omitted, the disclosure starts closed.
  • Disclosures sharing a group form an accordion: opening one closes the others.

Reach for tabs when the parts are peers a reader switches between, and a disclosure for detail most readers skip.