GrCard

Package: @feugene/granularitycoreGroup: data

A container for a meaningful block of content and actions.

Machine-translated from the Russian original, not yet reviewed. Read the original

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 headingtitle gives a real h2h6, which lets a report of six cards be navigated by structure;
  • the card is clickableclickable/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 layoutpadding, 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.

The default does not change

<GrCard> with no props renders a single <div> with a slot inside and exactly the same classes as before: 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

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:

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

Playground 6

Loading…

Code
<GrCard />

Install

npm i @feugene/granularity

Import

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

API

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 (`RouterLink`, Inertia’s `Link`). Stronger than `href`.
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.
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

EventTypeDescription
click[event: MouseEvent]

Examples 6

Actions next to the card title

Продажи за неделю

Обновлено 5 минут назад

Последнее действие:

Header Actions
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrCard, GrDropdownMenu, GrDropdownMenuItem } from '@feugene/granularity'

const lastAction = ref('')
</script>

<template>
  <!--
    Заголовок остаётся заголовком карточки — настоящим `h3` со своим уровнем и
    отступами, — а кнопка живёт в `#actions` справа от него. До этого слота
    карточке с действием приходилось забирать `#header` целиком и переписывать
    заголовок вручную.
  -->
  <GrCard title="Продажи за неделю" description="Обновлено 5 минут назад" :heading-level="3" padding="md">
    <template #actions>
      <GrButton size="xs" variant="ghost" @click="lastAction = 'Обновить'">
        Обновить
      </GrButton>

      <GrDropdownMenu placement="bottom-end">
        <template #trigger="{ triggerProps }">
          <GrButton v-bind="triggerProps" size="xs" variant="ghost" square aria-label="Ещё">

          </GrButton>
        </template>

        <GrDropdownMenuItem @click="lastAction = 'Экспорт'">
          Экспорт в CSV
        </GrDropdownMenuItem>
        <GrDropdownMenuItem @click="lastAction = 'Настройки'">
          Настроить период
        </GrDropdownMenuItem>
      </GrDropdownMenu>
    </template>

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Последнее действие: <strong>{{ lastAction }}</strong>
    </p>
  </GrCard>
</template>

Variants

elevated
Рамка, фон и тень — вид по умолчанию.
outlined
Без тени: плотные сетки не рябят.
ghost
Без рамки: карточка внутри карточки.
Открыто:

Variants
<script setup lang="ts">
import { ref } from 'vue'

import { GrCard } from '@feugene/granularity'

const opened = ref('')
</script>

<template>
  <div class="grid gap-4">
    <div class="grid gap-3 md:grid-cols-3">
      <GrCard padding="md" body-class="grid gap-1">
        <div class="text-sm font-semibold text-[var(--gr-fg)]">
          elevated
        </div>
        <div class="text-sm text-[var(--gr-muted-fg)]">
          Рамка, фон и тень — вид по умолчанию.
        </div>
      </GrCard>

      <GrCard padding="md" variant="outlined" body-class="grid gap-1">
        <div class="text-sm font-semibold text-[var(--gr-fg)]">
          outlined
        </div>
        <div class="text-sm text-[var(--gr-muted-fg)]">
          Без тени: плотные сетки не рябят.
        </div>
      </GrCard>

      <GrCard padding="md" variant="ghost" body-class="grid gap-1">
        <div class="text-sm font-semibold text-[var(--gr-fg)]">
          ghost
        </div>
        <div class="text-sm text-[var(--gr-muted-fg)]">
          Без рамки: карточка внутри карточки.
        </div>
      </GrCard>
    </div>

    <!-- Внутри кликабельной карточки не должно быть кнопок и ссылок:
         интерактив внутри интерактива ломает и клавиатуру, и скринридер. -->
    <GrCard clickable padding="md" body-class="grid gap-1" @click="opened = 'Отчёт за июль'">
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Отчёт за июль
      </div>
      <div class="text-sm text-[var(--gr-muted-fg)]">
        Вся поверхность кликабельна и достижима `Tab`.
      </div>
    </GrCard>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      Открыто: <span class="font-semibold text-[var(--gr-fg)]">{{ opened }}</span>
    </div>
  </div>
</template>

Basic surface with host-controlled spacing

Basic card
Карточка задаёт поверхность и отступы сама — потребителю остаётся содержимое.

Basic Surface
<script setup lang="ts">
import { GrButton, GrCard } from '@feugene/granularity'
</script>

<template>
  <!-- Отступ и раскладка тела — пропами карточки, а не `class` снаружи. -->
  <GrCard padding="md" body-class="grid gap-4">
    <div>
      <div class="text-sm font-semibold text-[var(--gr-fg)]">
        Basic card
      </div>
      <div class="mt-1 text-sm text-[var(--gr-muted-fg)]">
        Карточка задаёт поверхность и отступы сама — потребителю остаётся содержимое.
      </div>
    </div>

    <div class="flex flex-wrap gap-2">
      <GrButton size="sm">
        Primary action
      </GrButton>
      <GrButton size="sm" variant="outline">
        Secondary
      </GrButton>
    </div>
  </GrCard>
</template>

Cards as metric tiles

Error budget
98.4%
Healthy
Pending reviews
7
Attention
Blocked pipelines
2
Escalate

Kpi Grid
<script setup lang="ts">
import { GrBadge, GrCard } from '@feugene/granularity'

const metrics = [
  { id: 'budget', label: 'Error budget', value: '98.4%', tone: 'success', badge: 'Healthy' },
  { id: 'reviews', label: 'Pending reviews', value: '7', tone: 'warning', badge: 'Attention' },
  { id: 'pipelines', label: 'Blocked pipelines', value: '2', tone: 'danger', badge: 'Escalate' },
] as const
</script>

<template>
  <div class="grid gap-4 md:grid-cols-3">
    <GrCard
      v-for="metric in metrics"
      :key="metric.id"
      padding="sm"
      variant="outlined"
      body-class="grid gap-2"
    >
      <div class="text-sm text-[var(--gr-muted-fg)]">
        {{ metric.label }}
      </div>
      <div class="text-2xl font-semibold text-[var(--gr-fg)]">
        {{ metric.value }}
      </div>
      <GrBadge size="sm" :tone="metric.tone" radius="semi">
        {{ metric.badge }}
      </GrBadge>
    </GrCard>
  </div>
</template>

Section heading and description

Revenue by channel

August 2026, excluding refunds

Gross
$26,100
Refunds
$1,355
Net
$24,745

Documents

Gross
$26,100
Refunds
$1,355
Net
$24,745

Custom header

updated 2 min ago
Слот занят — `title` и `description` не рендерятся вовсе.

Section Heading
<script setup lang="ts">
import { GrCard, GrDescriptionList } from '@feugene/granularity'

const items = [
  { label: 'Gross', value: '$26,100' },
  { label: 'Refunds', value: '$1,355' },
  { label: 'Net', value: '$24,745' },
]
</script>

<template>
  <div class="grid gap-4">
    <!--
      `title` печатается настоящим `h2`…`h6`, а не жирной строкой: отчёт из
      нескольких карточек иначе нечем обойти по структуре — после `h1` страницы
      заголовков в нём нет.
    -->
    <GrCard
      title="Revenue by channel"
      description="August 2026, excluding refunds"
      :heading-level="3"
      variant="outlined"
      padding="md"
    >
      <GrDescriptionList :items="items" label-width="7rem" />
    </GrCard>

    <!--
      Типовая карточка отчёта: `padding` оставлен дефолтным, чтобы содержимое
      шло край в край, — и шапка всё равно с отступом. Она принадлежит самой
      карточке, а не содержимому, и прижатой к рамке быть не должна.
    -->
    <GrCard title="Documents" variant="outlined">
      <GrDescriptionList :items="items" layout="flow" class="px-4 py-3" />
    </GrCard>

    <!-- `#header` сильнее пропов: нестандартная шапка остаётся за потребителем. -->
    <GrCard variant="outlined" padding="md">
      <template #header>
        <div class="flex items-center justify-between gap-2">
          <h3 class="m-0 text-[length:var(--gr-text-sm)] font-600">
            Custom header
          </h3>
          <span class="text-[length:var(--gr-text-xs)] text-[var(--gr-muted-fg)]">updated 2 min ago</span>
        </div>
      </template>

      Слот занят — `title` и `description` не рендерятся вовсе.
    </GrCard>
  </div>
</template>

Action panel with badges and CTA group

Release checklist

2 blockers
Шапка и подвал — слоты карточки: разделители и отступы она расставляет сама.

Action Panel
<script setup lang="ts">
import { GrBadge, GrButton, GrCard } from '@feugene/granularity'
</script>

<template>
  <GrCard padding="md" body-class="grid gap-2">
    <template #header>
      <div class="flex flex-wrap items-center gap-2">
        <h3 class="m-0 text-sm font-semibold text-[var(--gr-fg)]">
          Release checklist
        </h3>
        <GrBadge size="sm" tone="info" radius="semi">
          2 blockers
        </GrBadge>
      </div>
    </template>

    <div class="text-sm text-[var(--gr-muted-fg)]">
      Шапка и подвал — слоты карточки: разделители и отступы она расставляет сама.
    </div>

    <template #footer>
      <div class="flex flex-wrap justify-end gap-2">
        <GrButton size="sm" variant="outline">
          Open runbook
        </GrButton>
        <GrButton size="sm">
          Resolve blockers
        </GrButton>
      </div>
    </template>
  </GrCard>
</template>

Component documentationAll components