Skip to content

Card

Card is one shallow visual surface. It renders one element, puts no anatomy around your content, and lets the application choose what that element truthfully means.

Use native headings, paragraphs, figures, lists, links, buttons, headers, and footers inside it. Use ordinary Tailwind for the product design. Card does not turn content into a “card schema.”

Card.vue

Installation

One command detects Vue, React, or Svelte and writes the matching one-file source into the conventional component directory:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add card

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

There is no initializer, provider, klean-ui.json, class helper, barrel file, card anatomy package, or runtime Klean dependency.

Usage

Choose the native element from what the content is, then write the markup directly.

Vue

ReleaseCard.vue
<script setup>
import Card from '@/components/ui/card/Card.vue'
</script>

<template>
  <Card as="article" aria-labelledby="release-title">
    <header>
      <p class="text-xs text-gray-500">Production</p>
      <h2 id="release-title" class="mt-1 text-lg font-semibold">API release</h2>
    </header>
    <p class="mt-3 leading-6 text-gray-600">
      Healthy in Lagos with three replicas.
    </p>
  </Card>
</template>

React

ReleaseCard.jsx
import Card from '@/components/ui/card/Card.jsx'

export default function ReleaseCard() {
  return (
    <Card as="article" aria-labelledby="release-title">
      <header>
        <p className="text-xs text-gray-500">Production</p>
        <h2 id="release-title" className="mt-1 text-lg font-semibold">
          API release
        </h2>
      </header>
      <p className="mt-3 leading-6 text-gray-600">
        Healthy in Lagos with three replicas.
      </p>
    </Card>
  )
}

Svelte

ReleaseCard.svelte
<script>
  import Card from '$lib/components/ui/card/Card.svelte'
</script>

<Card as="article" aria-labelledby="release-title">
  <header>
    <p class="text-xs text-gray-500">Production</p>
    <h2 id="release-title" class="mt-1 text-lg font-semibold">API release</h2>
  </header>
  <p class="mt-3 leading-6 text-gray-600">
    Healthy in Lagos with three replicas.
  </p>
</Card>

API

InputDefaultPurpose
asdivNative element or framework component that truthfully describes the complete surface.
class / classNameOrdinary Tailwind classes merged after the calm monochrome baseline.
native attributesDestinations, IDs, ARIA relationships, events, test hooks, and native element attributes.
default contentNative application markup, slots, children, or snippets with no inserted wrapper.
element referenceFramework-native access to the rendered surface when the application genuinely needs it.

There is no variant, tone, interactive, clickable, shadow, radius, padding, size, header, or status API. There is also no CardHeader, CardTitle, CardDescription, CardContent, or CardFooter.

Those names mostly repeat HTML and move Tailwind away from the element it styles.

Choose the truthful element

Card is not a semantic element. Its content determines the element:

Content or intentChoose
Pure layout grouping with no stronger meaningdefault div
Self-contained item that makes sense on its ownas="article"
Labelled part of the current pageas="section" plus its heading relationship
Tangential or supporting contentas="aside"
One destination for the whole surfacereal a or framework Link
One command performed by the whole surfacereal button
Several links, buttons, fields, or other interactive controlsnon-interactive Card with explicit controls

Do not use article merely because the result looks like a card. Use it only when the content passes the standalone test. A grid cell or decorative grouping can remain a div.

One destination can own the whole surface. Multiple actions cannot.

CardNavigation.vue

A whole-card Link preserves the URL, browser history, modified clicks, open-in-new-tab, focus, and keyboard activation. Pass the official Inertia Link directly through as; Card does not need a link, router, or navigate prop.

Never put a button, link, field, or menu trigger inside a Card that already renders as a link or button. Keep the outer Card non-interactive and make every child action explicit instead.

Styling with Tailwind

The neutral Card is deliberately calm: one light border, white surface, ordinary foreground, moderate padding, and matching dark classes. Caller Tailwind merges after that baseline and can replace every part:

vue
<Card
  as="article"
  class="rounded-none border-2 border-black bg-[#f7f3eb] p-8 text-black shadow-[6px_6px_0_0_#000] dark:bg-[#f7f3eb] dark:text-black"
>
  <!-- native product markup -->
</Card>

If a treatment repeats inside one application, make a small application-owned component or shared class recipe. Do not turn it into a Klean variant.

Hagfish and Slipway recipes

These treatments are proof that one source can serve different products. They are not Klean themes.

ProductCards.vue
Hagfish keeps multiple controls explicit on a non-interactive article. Slipway keeps a compact operational section. Neither expands the Card API.

Hagfish's existing summary-card behavior—currency cycling, compact financial formatting, tooltips, and invoice filters—remains product logic around Card. Slipway's health state, deployment work, density, and dark application chrome remain Slipway logic.

Accessibility

  • Choose the native element before adding ARIA. Card supplies no role by default.
  • Give every section or aside an accessible name through a visible heading and aria-labelledby when needed.
  • Keep heading levels aligned with the page outline; Card never chooses a heading.
  • Use a real anchor or framework Link for navigation and a real button for commands.
  • Give a whole-card anchor or button a visible focus-visible treatment when caller classes replace the baseline.
  • Keep interactive targets large enough and preserve readable contrast in caller-owned colors.
  • Do not communicate status through color alone; pair dots and tones with visible words.
  • Do not nest interactive content inside a whole-card link or button.

Durable behavior

Card owns no client state. It does not remember selection, expansion, dismissal, filters, or a destination.

This is deliberate:

  • a linked Card keeps its destination in markup and lets Inertia and the browser own history;
  • filter and pagination state belongs in the URL when another visit should reproduce the same view;
  • user preferences belong in durable storage only when they should survive visits;
  • expansion belongs to the component that actually owns disclosure behavior;
  • pending and server outcomes belong to the real button, form, Toast, or Alert that communicates the operation.

The same inputs produce the same server-rendered element. Card adds no mount-time state, storage read, event listener, or cleanup lifecycle.

When to use

Use Card for a repeated visual boundary around self-contained summaries, linked resources, account panels, pricing choices, release notes, dashboard metrics, and compact operational groups.

Use it when the outer surface is genuinely shared while the content remains ordinary application markup.

When not to use

  • Use plain layout markup when a one-off div with spacing is clearer than introducing a component.
  • Use Alert when the surface communicates guidance, an operation result, or an urgent failure.
  • Use Table when rows and columns describe comparable data relationships.
  • Use Dialog or Popover for an overlay with focus and dismissal behavior.
  • Use Button for an action without a card-sized content group.
  • Do not use Card to hide finance formatting, data fetching, status maps, route construction, carousel behavior, or application state.

Complete framework source

Copy, inspect, and change the complete one-file source for your framework.

Vue source

Card.vue
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({ inheritAttrs: false })

defineProps({
  /** Native element or framework component that truthfully describes the surface. */
  as: { type: [String, Object, Function], default: 'div' }
})

const attrs = useAttrs()
const element = ref()

const forwardedAttrs = computed(() => {
  const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
  return rest
})

defineExpose({ element })
</script>

<template>
  <component
    :is="as"
    ref="element"
    v-bind="forwardedAttrs"
    data-slot="card"
    :class="
      twMerge(
        'rounded-lg border border-gray-200 bg-white p-5 text-gray-950 dark:border-gray-800 dark:bg-gray-950 dark:text-white',
        attrs.class
      )
    "
  >
    <slot />
  </component>
</template>

React source

Card.jsx
import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'

const BASE_CLASSES =
  'rounded-lg border border-gray-200 bg-white p-5 text-gray-950 dark:border-gray-800 dark:bg-gray-950 dark:text-white'

const Card = forwardRef(function Card(
  { as: Component = 'div', className, 'data-slot': _dataSlot, ...props },
  ref
) {
  return (
    <Component
      {...props}
      ref={ref}
      data-slot="card"
      className={twMerge(BASE_CLASSES, className)}
    />
  )
})

export default Card

Svelte source

Card.svelte
<script>
  import { twMerge } from "tailwind-merge";

  const BASE_CLASSES =
    "rounded-lg border border-gray-200 bg-white p-5 text-gray-950 dark:border-gray-800 dark:bg-gray-950 dark:text-white";

  let {
    as = "div",
    children,
    class: className,
    "data-slot": _dataSlot,
    ...props
  } = $props();

  let element = $state();

  export function getElement() {
    return element;
  }
</script>

{#if typeof as === "string"}
  <svelte:element
    this={as}
    {...props}
    bind:this={element}
    data-slot="card"
    class={twMerge(BASE_CLASSES, className)}
  >
    {@render children?.()}
  </svelte:element>
{:else}
  {@const Component = as}
  <Component
    {...props}
    bind:this={element}
    data-slot="card"
    class={twMerge(BASE_CLASSES, className)}
  >
    {@render children?.()}
  </Component>
{/if}

  • Button — supplies truthful child actions or a whole-card button command.
  • Breadcrumb and Tabs — describe location and peer navigation outside card content.
  • Alert — communicates guidance, status, or failure with explicit announcement semantics.
  • Table — preserves two-dimensional data instead of turning every row into a card.
  • Dialog and Popover — add native overlay and dismissal behavior when a surface must float.

All open source projects are released under the MIT License.