GrCard
A container for a meaningful block of content and actions.
Overview
When to take it
- the content needs a surface — a border, a background, a radius and a shadow in one component instead of classes of your own;
- the block has a heading —
titlegives a realh2…h6, which lets a report of six cards be navigated by structure; - the card is clickable —
clickable/hrefgive the role and the keyboard as a whole, rather than an@clickon adiv; - there are many cards — the shared look is held by
variant, not by a copy of the classes in every place; - the padding is set by the layout —
padding, includingnonefor a table or an edge-to-edge picture.
When to take something else
| Need | Take |
|---|---|
| The structure of a window header, body and footer is needed | GrDialog |
| The card is a widget on a dashboard grid | GrDashboardItem |
| The card explains an emptiness | GrEmptyState |
| There is a list of rows inside | GrList |
| Only a separator is needed | GrDivider |
<GrCard> with no props renders a single <div> with a slot: no wrappers, no
padding. That makes it possible to put it under any layout without fighting
someone else’s margins.
Variants every tone and view, rendered by the real component
variant
elevatedoutlinedghost
The grid is live: hover and press it. Hover, focus and active states are not drawn here — they belong to the browser, not to a prop.
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrCard
title="Workspace"
description="Billing and access"
padding="md"
>
Three projects, twelve members.
</GrCard>Examples 5 each with a live demo and its code
The whole anatomy in one card
The title and the description are props, not markup: the card prints a real heading above a muted line.
actionsputs the status next to it without giving up that heading,body-classlays out the body, andfooterholds what you do with the card — buttons in the body would compete with the content for attention.Team plan
€48 per seat a month, billed yearly
ActiveSeats9 of 12Storage182 GB of 500 GBAutomation runs8,740 of 10,000Renews on 12 March 2027Buttons inside, a link in the title
A card with its own buttons cannot be a link: interactive inside interactive breaks the keyboard and the screen reader.
hoverablelights the surface up without making it one, theheaderslot carries a heading with the link, andas="article"marks each card as a self-contained entry.GWRetry webhook deliveries with backoff
#4182 · Grace Wu · 2 hours ago
Failed deliveries are retried five times with exponential backoff instead of being dropped after the first 5xx.
backendreliability+142−38 · 6 filesLRMove invoice PDFs to the EU bucket
#4179 · Linus Ray · yesterday
New invoices are written to eu-central-1; a one-off job migrates the 18 400 existing files.
compliance+61−12 · 3 filesA table edge to edge
Without
paddingthe body has no inset, so a table runs from border to border, while the header the card draws itself keeps its own.as="section"withregion-labelmakes the card a named region of the page, andheading-levelfits the title into the page outline.Recent invoices
Last 30 days · 128 invoices
Invoice Customer Issued Amount Status INV-2041 Northwind Traders 2 Oct 2026 €4,320.00 Paid INV-2040 Contoso Ltd 29 Sep 2026 €12,850.00 Due INV-2039 Fabrikam Inc 24 Sep 2026 €960.00 Overdue INV-2038 Tailspin Toys 19 Sep 2026 €2,475.50 Paid Showing 4 of 128View all invoicesThe whole card as a link or a button
With
hrefthe card is one link and one tab stop, named by its title; withclickableit is a button that emitsclick. A grid of cards takesvariantandpaddingonce fromGrConfigProviderinstead of repeating them on every card.
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
variant | GrCardVariant | undefined | undefined | `elevated` — a border, a background and a shadow; `outlined` — no shadow; `ghost` — no border. |
title | string | undefined | undefined | The heading of the card — a real `h2`…`h6` rather than a bold line: a report of six cards cannot otherwise be walked by structure, as after the `h1` of the page it holds not a single heading. |
as | string | Component | undefined | undefined | A root tag of your own: a link component (`RouterLink`, Inertia’s `Link`) or a semantic tag of the page (`section`, `article`, `aside`). Stronger than `href`. A non-interactive tag is only a replacement for `div`: no highlight, no focus ring. |
href | string | undefined | undefined | A card that is a link. |
padding | GrCardPadding | undefined | undefined | The inner padding. `none` by default: a card is a surface, and its content (`GrCollapse`, `GrList`) knows about its own padding. |
regionLabel | string | undefined | undefined | The name of the card as a region of the page: `role="region"` plus `aria-label`. A nameless `<section>` is an ordinary container to a screen reader, and that is the norm: a page needs few named regions, otherwise their overview stops helping. Not given to an interactive card — `role="region"` on a `<button>` is invalid. |
clickable | boolean | undefined | false | A card that is a button: the whole surface is interactive. |
hoverable | boolean | undefined | false | A highlight on hover without interactivity. |
bodyClass | string | undefined | undefined | The classes of the wrapper of the body — it appears together with the sections. |
description | string | undefined | undefined | An explanation under the heading. |
headingLevel | 2 | 3 | 4 | 5 | 6 | undefined | undefined | The level of the heading to fit the structure of the page. Unset — from `GrConfigProvider`, otherwise `3`. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The content of the card. |
header | any | A header instead of the `title` and `description` props. |
actions | any | Actions on the right in the card’s own header: "⋯", "Refresh", a period switch. They switch the header on the same way `title` does. It does not combine with the `#header` slot: that one replaces the header as a whole, and everything in it is drawn by the consumer. |
footer | any | The footer: actions, a summary, pagination. |
Events
| Event | Signature | Description |
|---|---|---|
click | (event: "click", event: MouseEvent): void | — |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrCardProps, GrCardEmits, GrCardConfigurableProps } from '@feugene/granularity/components/GrCard'Filedist/types/components/GrCard/GrCard.vue.d.ts
interface GrCardProps {
padding?: GrCardPadding;
variant?: GrCardVariant;
as?: string | Component;
href?: string;
regionLabel?: string;
clickable?: boolean;
hoverable?: boolean;
bodyClass?: string;
title?: string;
description?: string;
headingLevel?: GrCardHeadingLevel;
}interface GrCardEmits {
(e: 'click', event: MouseEvent): void;
}Filedist/types/components/GrCard/config.d.ts
const grCardConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCard">;Filedist/types/components/GrCard/defaults.d.ts
interface GrCardConfigurableProps {
padding: GrCardPadding;
variant: GrCardVariant;
headingLevel: GrCardHeadingLevel;
}Filedist/types/components/GrCard/grCardStyles.d.ts
type GrCardPadding = 'none' | 'sm' | 'md' | 'lg';type GrCardVariant = 'elevated' | 'outlined' | 'ghost';type GrCardHeadingLevel = typeof GR_CARD_HEADING_LEVELS[number];Filedist/types/components/GrCard/safelist.d.ts
const grCardSafelist: string[];Usage from the library prose
The default is one surface and nothing beyond
<GrCard> with no props renders a single <div> with a slot inside: no wrappers,
no padding. This is not caution for the sake of caution — GrCollapse and GrList
stand on GrCard, and a shifted default would move under them. Hence
padding="none" by default.
Padding
<GrCard padding="md">
…
</GrCard>none (the default), sm (12px), md (16px), lg (24px). While there are no
sections, the padding lives on the surface itself — no extra wrapper appears.
Variants
| Value | What it gives |
|---|---|
elevated (the default) | border + background + shadow |
outlined | border + background, no shadow |
ghost | background only — a card inside a card does not draw a second border |
padding and variant are read from GrConfigProvider:
<GrConfigProvider :component-defaults="{ GrCard: { padding: 'md', variant: 'outlined' } }">Sections
<GrCard padding="md">
<template #header>
<h3>Release 0.15</h3>
</template>
The body of the card.
<template #footer>
<GrButton size="sm">Publish</GrButton>
</template>
</GrCard>The header is separated with border-b, the footer with border-t. As soon as at
least one section appears (or bodyClass), the padding moves from the surface to
the sections — otherwise it would double up.
The card separates what it draws itself. A header made from title is its own,
and padding is due to it regardless of padding, including the default none:
otherwise the heading and the divider under it end up pressed against the border.
This is exactly the typical report card — a heading with padding, a table edge to
edge:
<GrCard title="Documents" variant="outlined">
<GrDataTable :rows="rows" :columns="columns" />
</GrCard>The #header and #footer slots are filled by the consumer — their padding comes
from padding and remains their concern. The rule is the same as the priority
rule: #header is stronger than the props and replaces the header as a whole,
and the responsibility for padding along with it. Otherwise a consumer with padding
of their own inside the slot would get it twice over.
bodyClass puts classes on the wrapper of the body — the very place where
grid gap-* is usually needed.
The heading is real, not a bold line
<GrCard title="Revenue for the month" description="Excluding refunds" padding="md">
<RevenueChart />
</GrCard>title is printed with an h2…h6 tag (headingLevel, h3 by default) rather
than as <span class="font-semibold">. The difference is not cosmetic: a page of
six cards where there is not a single heading after the h1 is walked by a screen
reader in sequence only — there is nothing to jump between blocks by.
#header is stronger than the props: if the slot is taken, title and
description are not rendered at all. A non-standard header does not have to
explain why it is not title.
An action beside the heading is the #actions slot, and the header stays its
own: the heading is printed with the same h2…h6, the padding comes from the scale
of the card.
<GrCard title="Sales for the week" :heading-level="3">
<template #actions>
<GrButton size="xs" variant="ghost">Refresh</GrButton>
</template>
</GrCard>The slot did not appear for the sake of convenience: there was no third case —
either a heading from the props, or #header as a whole — and any button in the
header forced you to take over the slot and rewrite the heading by hand.
GrDashboardItem in the ring and the wrappers of consumers arrived at that
workaround independently, and all of them lost the same things: the real heading
level and the padding scale.
The slot does not combine with #header: that one replaces the header as a whole,
including the place the actions would take. GrDashboardItem still uses #header —
it has a drag handle to the left of the heading, and #actions does not express
that.
A card-link takes its accessible name from the heading (aria-labelledby) rather
than from all of the content in a row.
A clickable card will not have a heading. Its root is a <button>, and its
content model is phrasing content: an <h3> inside is invalid. title is printed
as a <span>, and in dev the component warns about it. If both a heading and a
transition are needed — hoverable plus a link in the heading itself; this is the
same device as with actions inside (below).
A card-link and a card-button
<GrCard href="/reports/42" padding="md">
…
</GrCard>
<GrCard clickable padding="md" @click="open">
…
</GrCard>
<GrCard :as="RouterLink" :to="{ name: 'report' }" padding="md">
…
</GrCard>The order in which the tag is chosen is as → <a href> → <button clickable> →
<div>, as in GrLink and GrListItem. An interactive card gets a focus ring and
a highlight on hover.
Interactivity comes from an allowed tag, not from the mere fact of as. The
focus ring goes to whoever can take focus: a <button>, an <a> with a link and a
router component, about which all that is known before rendering is that it renders
an <a>. as="section" does not make the card interactive — see below.
A restriction: buttons and links must not be put inside a clickable card —
interactive inside interactive breaks both the keyboard and the screen reader (axe:
nested-interactive). A card with actions inside needs hoverable plus a link in
the heading — visually it is the same thing, semantically it is correct.
hoverable highlights the card without making it interactive.
The semantics of the page: as is a replacement of the tag
A page on which a block has to be a <section> or an <article> by the markup of
the document takes as with a string tag:
<GrCard as="section" title="Sales" padding="md">
…
</GrCard>Only the tag changes. The classes, the padding, the heading and the slots are
the same as with a <div>: a non-interactive tag gets neither a highlight under the
cursor, nor a focus ring, nor aria-labelledby. The card stays a surface, and the
markup of the page becomes honest.
<div> stays the default on purpose. The HTML spec advises against section where
the element is needed for styling, and a card is most often exactly a surface —
GrCollapse, GrList and GrSortableList stand on it in precisely that capacity.
The tag is chosen by the page, which knows its structure, not by the component.
The other side of the rule: clickable or href together with a tag that does not
enter the tab order is a markup error. The card will be clickable with the mouse and
stay unreachable from the keyboard, and in dev the component warns about it.
A card as a region of the page
A nameless <section> is an ordinary container to a screen reader, and that is the
norm. A name turns it into a landmark, and a page needs few named regions: a
dozen region cards make their overview useless. That is why a name is given on
explicit request — regionLabel:
<GrCard as="section" region-label="Sales for August" title="Sales" padding="md">
…
</GrCard>The prop sets role="region" and aria-label — the same device as in
GrTable and GrFormSection.
An interactive card is not given it, and in dev it warns: role="region" on top of
a <button> is invalid, and a link has already been named by its heading.
Props in this example