Card

Gathers a group of content onto one raised surface.

Sakura no Uta

Ongoing

On my first day at the new school, I met a girl on the roof holding an old camera.

12 volumes

<script setup lang="ts">
  import { Button, Card, Divider, Heading, Inline, Stack, Tag, Text } from '@hina-ui/vue'
</script>

<template>
  <Card class="w-full max-w-sm">
    <Stack>
      <Stack gap="xs">
        <Inline justify="between" align="start" :wrap="false">
          <Heading :level="3" size="md">Sakura no Uta</Heading>
          <Tag tone="success">Ongoing</Tag>
        </Inline>
        <Text tone="muted" size="sm">
          On my first day at the new school, I met a girl on the roof holding an old camera.
        </Text>
      </Stack>
      <Divider />
      <Inline justify="between">
        <Text tone="faint" size="sm">12 volumes</Text>
        <Button variant="ghost" tone="neutral" size="sm">Open</Button>
      </Inline>
    </Stack>
  </Card>
</template>
vue

Usage

import { Card } from '@hina-ui/vue'
ts

A card is a surface: its own fill, a hairline border and a resting shadow, one step above the page ground. Content goes into the default slot.

A card brings its own background, a hairline border and a resting shadow. Content goes straight into the default slot.

<script setup lang="ts">
  import { Card, Text } from '@hina-ui/vue'
</script>

<template>
  <Card class="w-full max-w-sm">
    <Text>
      A card brings its own background, a hairline border and a resting shadow. Content goes
      straight into the default slot.
    </Text>
  </Card>
</template>
vue

The card owns the surface, not the arrangement inside it. Leave direction and spacing to layout components such as Stack and Inline.

Examples

Padding

Padding is on by default and follows the current density. For content that should run to the edge — images, tables — set padded to false and let an inner container supply the padding instead. The card then clips its overflow, so the square corners of edge-to-edge content cannot cover the card radius.

A slope on a summer afternoon

The slope down to the station

The image runs to the edge, and an inner container supplies the padding instead.

<script setup lang="ts">
  import { Card, Heading, Image, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Card :padded="false" class="w-full max-w-sm overflow-hidden">
    <Image src="/sample.webp" alt="A slope on a summer afternoon" class="h-32 w-full" />
    <Stack gap="xs" class="p-[var(--hn-panel-p)]">
      <Heading :level="3" size="base">The slope down to the station</Heading>
      <Text tone="muted" size="sm">
        The image runs to the edge, and an inner container supplies the padding instead.
      </Text>
    </Stack>
  </Card>
</template>
vue

Add overflow-hidden when the content needs to be clipped to the corner radius.

Semantic element

as sets the rendered element. It defaults to div; reach for something else only when the semantics are more precise: article for content that stands on its own, section for a block within the page, and — when a set of cards is a list — ul on the container with li on each card.

Use article for content that stands on its own

Use section for a block within the page

<script setup lang="ts">
  import { Card, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Stack class="w-full max-w-sm">
    <Card as="article">
      <Text>Use article for content that stands on its own</Text>
    </Card>
    <Card as="section">
      <Text>Use section for a block within the page</Text>
    </Card>
  </Stack>
</template>
vue

Clickable cards

A card carries no clickable styling of its own: no hover tint, no shadow step. When the whole card should be pressable, the caller composes it: as="button" for the element, hn-interactive for the pointer and focus ring, hn-state-layer for the hover ink, hn-press-lg to scale the press down to something suited to a large target, plus a Ripple.

<script setup lang="ts">
  import { Card, Heading, Ripple, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Card as="button" class="hn-interactive hn-state-layer hn-press-lg w-full max-w-sm text-start">
    <Ripple />
    <Stack gap="xs">
      <Heading :level="3" size="base">The whole card is one press target</Heading>
      <Text tone="muted" size="sm">
        Ink on hover and a ripple on press — the caller opts into both.
      </Text>
    </Stack>
  </Card>
</template>
vue

Don’t do this when only one part of the card is clickable — put a Button or Link there and leave the card static.

Density

Padding reads a density token, so setting data-density="compact" on any ancestor tightens every card inside it. Nothing needs to be passed to the card.

comfortable

20px of padding

compact

14px of padding

<script setup lang="ts">
  import { Card, Inline, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline align="start" class="w-full max-w-lg">
    <Card class="flex-1">
      <Stack gap="xs">
        <Text size="sm" tone="faint">comfortable</Text>
        <Text>20px of padding</Text>
      </Stack>
    </Card>
    <Card data-density="compact" class="flex-1">
      <Stack gap="xs">
        <Text size="sm" tone="faint">compact</Text>
        <Text>14px of padding</Text>
      </Stack>
    </Card>
  </Inline>
</template>
vue

Accessibility

  • A card renders a div by default. It carries no role, and screen readers announce its content as ordinary content.
  • When the whole card is clickable it must become a button or a link. Never attach a click handler to a div: the keyboard can’t reach it and screen readers won’t announce it as a control.
  • Use Heading for titles inside a card and pick a level that continues the page’s existing heading outline.

API

Props

Prop
Type
Default
Description
padded
boolean
true
Whether the card has padding; follows density, and clips overflow when off
as
string
'div'
The rendered element
as-child
boolean
false
Render no element of its own and merge the styles onto the sole child
class
string
—
Classes appended to the root element

Slots

Slot
Description
default
The card’s content