GrCard

  • core
  • data
  • API · 11 props

A container for a meaningful block of content and actions.

import { GrCard } from '@feugene/granularity/components/GrCard'

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 — title gives a real h2…h6, which lets a report of six cards be navigated by structure;
  • the card is clickable — clickable/href give the role and the keyboard as a whole, rather than an @click on a div;
  • 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, including none for a table or an edge-to-edge picture.

When to take something else

NeedTake
The structure of a window header, body and footer is neededGrDialog
The card is a widget on a dashboard gridGrDashboardItem
The card explains an emptinessGrEmptyState
There is a list of rows insideGrList
Only a separator is neededGrDivider

<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

  • elevated
  • outlined
  • ghost

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

Stage

Loading…

0 changed· state is in the URL

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. actions puts the status next to it without giving up that heading, body-class lays out the body, and footer holds 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

    Active
    Seats9 of 12
    Storage182 GB of 500 GB
    Automation runs8,740 of 10,000
    Renews on 12 March 2027
  • Buttons 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. hoverable lights the surface up without making it one, the header slot carries a heading with the link, and as="article" marks each card as a self-contained entry.

    GW

    Retry 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 files
    LR

    Move 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 files
  • A table edge to edge

    Without padding the body has no inset, so a table runs from border to border, while the header the card draws itself keeps its own. as="section" with region-label makes the card a named region of the page, and heading-level fits the title into the page outline.

    Recent invoices

    Last 30 days · 128 invoices

    InvoiceCustomerIssuedAmountStatus
    INV-2041Northwind Traders2 Oct 2026€4,320.00Paid
    INV-2040Contoso Ltd29 Sep 2026€12,850.00Due
    INV-2039Fabrikam Inc24 Sep 2026€960.00Overdue
    INV-2038Tailspin Toys19 Sep 2026€2,475.50Paid
    Showing 4 of 128View all invoices
  • The whole card as a link or a button

    With href the card is one link and one tab stop, named by its title; with clickable it is a button that emits click. A grid of cards takes variant and padding once from GrConfigProvider instead of repeating them on every card.

API generated from the package · cannot drift

Props

PropTypedefaultDescription
variantGrCardVariant | undefinedundefined`elevated` — a border, a background and a shadow; `outlined` — no shadow; `ghost` — no border.
titlestring | undefinedundefinedThe 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.
asstring | Component | undefinedundefinedA 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.
hrefstring | undefinedundefinedA card that is a link.
paddingGrCardPadding | undefinedundefinedThe inner padding. `none` by default: a card is a surface, and its content (`GrCollapse`, `GrList`) knows about its own padding.
regionLabelstring | undefinedundefinedThe 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.
clickableboolean | undefinedfalseA card that is a button: the whole surface is interactive.
hoverableboolean | undefinedfalseA highlight on hover without interactivity.
bodyClassstring | undefinedundefinedThe classes of the wrapper of the body — it appears together with the sections.
descriptionstring | undefinedundefinedAn explanation under the heading.
headingLevel2 | 3 | 4 | 5 | 6 | undefinedundefinedThe level of the heading to fit the structure of the page. Unset — from `GrConfigProvider`, otherwise `3`.

Slots

SlotTypeDescription
defaultanyThe content of the card.
headeranyA header instead of the `title` and `description` props.
actionsanyActions 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.
footeranyThe footer: actions, a summary, pagination.

Events

EventSignatureDescription
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
import type { GrCardProps, GrCardEmits, GrCardConfigurableProps } from '@feugene/granularity/components/GrCard'

Filedist/types/components/GrCard/GrCard.vue.d.ts

GrCardPropsinterface
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;
}
GrCardEmitsinterface
interface GrCardEmits {
    (e: 'click', event: MouseEvent): void;
}

Filedist/types/components/GrCard/config.d.ts

grCardConfigconst
const grCardConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCard">;

Filedist/types/components/GrCard/defaults.d.ts

GrCardConfigurablePropsinterface
interface GrCardConfigurableProps {
    padding: GrCardPadding;
    variant: GrCardVariant;
    headingLevel: GrCardHeadingLevel;
}

Filedist/types/components/GrCard/grCardStyles.d.ts

GrCardPaddingtype
type GrCardPadding = 'none' | 'sm' | 'md' | 'lg';
GrCardVarianttype
type GrCardVariant = 'elevated' | 'outlined' | 'ghost';
GrCardHeadingLeveltype
type GrCardHeadingLevel = typeof GR_CARD_HEADING_LEVELS[number];

Filedist/types/components/GrCard/safelist.d.ts

grCardSafelistconst
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
vue
<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
ValueWhat it gives
elevated (the default)border + background + shadow
outlinedborder + background, no shadow
ghostbackground only — a card inside a card does not draw a second border

padding and variant are read from GrConfigProvider:

vue
<GrConfigProvider :component-defaults="{ GrCard: { padding: 'md', variant: 'outlined' } }">
Sections
vue
<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:

vue
<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
vue
<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.

vue
<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).

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:

vue
<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:

vue
<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.