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.
<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 · type | Default | Description |
|---|---|---|
| State | ||
| loadingboolean | The 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= false | false | There 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. |
| dividedboolean | The 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= true | true | The 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= true | true | The 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 | danger | Meaning 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= false | false | The 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 need | Take |
|---|---|
| A list screen with a toolbar and pages | Data table |
| A chart with its figure | Chart card |
| One row of text with a thing at its end | List item |
| A choice among cards | Radio group card |
| A notice with an action | List item in a tone |