Skip to content

Card

Group one part of a page into a card with a title, content and an optional footer. Built on it: No data.

Billing

You are on the Pro plan, €24 a month for up to 10 people. The next invoice goes out on 1 October 2026 and is paid by the card ending 4242.

Renews 1 Oct
<script setup lang="ts">import { UiCard, UiButton } from 'grani'</script> <template>  <UiCard title="Billing" class="w-full max-w-md">    <p class="text-sm text-fg">      You are on the Pro plan, €24 a month for up to 10 people. The next invoice goes out on 1      October 2026 and is paid by the card ending 4242.    </p>    <template #footer>      <span class="me-auto text-xs text-fg-muted">Renews 1 Oct</span>      <UiButton variant="secondary">Invoices</UiButton>      <UiButton>Change plan</UiButton>    </template>  </UiCard></template>

Also called panel, section card.

Usage

<UiCard>…</UiCard>

With GraniResolver in the Vite config, a template needs no import: set it up once.

API

Name · typeDefaultDescription
State
loadingbooleanThe body is on its way. With nothing in the body yet (the first load) a skeleton stands in its place; over content (a refresh) the content stays, dimmed to 60% with the title, under one spinner in the card's middle; #actions, a whole #header and #footer keep their color and still work. aria-busy either way. Bind it (false too) where the card loads, so its live region is there before the words. A body with a shape of its own (a table, a chart) draws its own skeleton.
emptyboolean= falsefalseThere is nothing to show: #empty stands in the body's place, in the middle. A first load's skeleton wins over it; a later load leaves it as it is: give its Retry loading.
Behavior
titlestring= """"The heading of the card; its level follows the nesting: h2 on a page, h3 in a card, and so on.
descriptionstring= """"One line under the title that says what the card holds.
dividedbooleanThe line under the header; on by default for a framed card, off for a section of an overlay (the overlay draws the lines between sections). false lets the body follow the title directly.
paddedboolean= truetrueThe body's padding; false runs it edge to edge, for rows, a table or an image. Rows keep their text on the gutter.
imagePositionstart | top | bottom | end= "top""top"Where #image stands: above the header, below the footer, or beside the card at the start or the end.
footerVariantplain | muted= "plain""plain"muted fills the footer; plain keeps it on the surface. The line above it stays either way.
footerPaddedboolean= truetrueThe footer's padding; false runs its children edge to edge, sharing the width: split buttons, tabs.
Look
variantoutline | plain= "outline""outline"outline is the framed surface; plain has no frame, no fill, no padding of its own: the three parts alone, on the gutter around it.
sizesm | md= "sm""sm"The gutter: sm is --space-surface (16, the default), md is --space-surface-md (24). Rows, separators and lists inside line up on it.
alignstart | center= "start""start"center stands the body's content in the middle, with room around it: a 404; empty puts #empty there.
tonewarning | dangerMeaning of the frame: danger for a card in trouble (a failed connection), warning for one that needs a look. The words say it too: a badge in #actions.
Form and access
disabledboolean= falsefalseThe card cannot be used now: its words go muted and it is a disabled group named by its title. Controls inside are the caller's to disable.

Anything else lands on the root element: native attributes, aria-*, data-testid.

When to use another component

When you needTake
A list screen with a toolbar and pagesData table
A chart with its figureChart card
One row of text with a thing at its endList item
A choice among cardsRadio group card
A notice with an actionList item in a tone