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.”
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.
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
<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
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
<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
| Input | Default | Purpose |
|---|---|---|
as | div | Native element or framework component that truthfully describes the complete surface. |
class / className | — | Ordinary Tailwind classes merged after the calm monochrome baseline. |
| native attributes | — | Destinations, IDs, ARIA relationships, events, test hooks, and native element attributes. |
| default content | — | Native application markup, slots, children, or snippets with no inserted wrapper. |
| element reference | — | Framework-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 intent | Choose |
|---|---|
| Pure layout grouping with no stronger meaning | default div |
| Self-contained item that makes sense on its own | as="article" |
| Labelled part of the current page | as="section" plus its heading relationship |
| Tangential or supporting content | as="aside" |
| One destination for the whole surface | real a or framework Link |
| One command performed by the whole surface | real button |
| Several links, buttons, fields, or other interactive controls | non-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.
Navigation and actions
One destination can own the whole surface. Multiple actions cannot.
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:
<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.
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
sectionorasidean accessible name through a visible heading andaria-labelledbywhen 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-visibletreatment 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
divwith 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
<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
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
<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}
Related components
- 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.