Soft card
A card whose body sits inset on a soft background, for grouping a section of a settings page.
API keys
Keys let a server talk to the API on your behalf.
No key created yet.
import { SoftCard } from '@/components/soft-card'
export default function Example() {
return (
<SoftCard title="API keys" description="Keys let a server talk to the API on your behalf.">
<div className="px-(--card-section-px) py-(--card-section-py)">
<p className="text-sm/6 text-zinc-500">No key created yet.</p>
</div>
</SoftCard>
)
}<script setup lang="ts">
import SoftCard from '@/components/soft-card.vue'
</script>
<template>
<SoftCard title="API keys" description="Keys let a server talk to the API on your behalf.">
<div class="px-(--card-section-px) py-(--card-section-py)">
<p class="text-sm/6 text-zinc-500">No key created yet.</p>
</div>
</SoftCard>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import clsx from 'clsx'
import type React from 'react'
import { useId } from 'react'
export function SoftCard({
title,
description,
action,
footer,
dark = false,
level = 3,
className,
children,
...props
}: {
title: string
description?: string
action?: React.ReactNode
footer?: React.ReactNode
dark?: boolean
level?: 1 | 2 | 3 | 4 | 5 | 6
} & React.ComponentPropsWithoutRef<'section'>) {
let titleId = useId()
let Heading = `h${level}` as const
return (
<section
aria-labelledby={titleId}
{...props}
className={clsx(
className,
'group flex flex-col rounded-2xl [--card-inset:--spacing(1)] [--card-px:--spacing(6)] [--card-section-px:calc(var(--card-px)-var(--card-inset))] [--card-section-py:var(--card-px)] [:where(&)]:py-(--card-inset)',
dark ? 'bg-zinc-900 shadow-lg dark:bg-zinc-800/50 dark:shadow-none' : 'bg-zinc-50 dark:bg-zinc-800/50'
)}
>
<header className="flex flex-wrap items-start justify-between gap-4 [:where(&)]:px-(--card-px) [:where(&)]:py-4">
<div className="min-w-0 flex-1 basis-64">
<Heading
id={titleId}
className={clsx(
'text-base/6 font-semibold sm:text-sm/6',
dark ? 'text-white' : 'text-zinc-950 dark:text-white'
)}
>
{title}
</Heading>
{description && (
<p
className={clsx(
'mt-0.5 text-base/6 text-pretty sm:text-sm/6',
dark ? 'text-zinc-400' : 'text-zinc-500 dark:text-zinc-400'
)}
>
{description}
</p>
)}
</div>
{action && <div className="flex shrink-0 items-center gap-3 whitespace-nowrap">{action}</div>}
</header>
<div
className={clsx(
'mx-(--card-inset) flex-1 overflow-hidden rounded-[calc(var(--radius-2xl)-var(--spacing))] ring-1',
dark
? 'bg-black/40 ring-white/5'
: 'bg-white shadow-[0_1px_2px_0_rgba(25,28,33,0.06),0_0_2px_0_rgba(0,0,0,0.08)] ring-zinc-950/5 dark:bg-black/40 dark:shadow-none dark:ring-white/5'
)}
>
{children}
</div>
{footer && (
<footer className="[:where(&)]:px-(--card-px) [:where(&)]:pt-4 [:where(&)]:pb-3">{footer}</footer>
)}
</section>
)
}<script setup lang="ts">
import { useId } from 'vue'
withDefaults(defineProps<{ title: string; description?: string; dark?: boolean; level?: 1 | 2 | 3 | 4 | 5 | 6 }>(), { level: 3 })
const titleId = useId()
</script>
<template>
<section
:aria-labelledby="titleId"
class="group flex flex-col rounded-2xl [--card-inset:--spacing(1)] [--card-px:--spacing(6)] [--card-section-px:calc(var(--card-px)-var(--card-inset))] [--card-section-py:var(--card-px)] [:where(&)]:py-(--card-inset)"
:class="dark ? 'bg-zinc-900 shadow-lg dark:bg-zinc-800/50 dark:shadow-none' : 'bg-zinc-50 dark:bg-zinc-800/50'"
>
<header class="flex flex-wrap items-start justify-between gap-4 [:where(&)]:px-(--card-px) [:where(&)]:py-4">
<div class="min-w-0 flex-1 basis-64">
<component
:is="`h${level}`"
:id="titleId"
class="text-base/6 font-semibold sm:text-sm/6"
:class="dark ? 'text-white' : 'text-zinc-950 dark:text-white'"
>
{{ title }}
</component>
<p
v-if="description"
class="mt-0.5 text-base/6 text-pretty sm:text-sm/6"
:class="dark ? 'text-zinc-400' : 'text-zinc-500 dark:text-zinc-400'"
>
{{ description }}
</p>
</div>
<div v-if="$slots.action" class="flex shrink-0 items-center gap-3 whitespace-nowrap">
<slot name="action" />
</div>
</header>
<div
class="mx-(--card-inset) flex-1 overflow-hidden rounded-[calc(var(--radius-2xl)-var(--spacing))] ring-1"
:class="
dark
? 'bg-black/40 ring-white/5'
: 'bg-white shadow-[0_1px_2px_0_rgba(25,28,33,0.06),0_0_2px_0_rgba(0,0,0,0.08)] ring-zinc-950/5 dark:bg-black/40 dark:shadow-none dark:ring-white/5'
"
>
<slot />
</div>
<footer v-if="$slots.footer" class="[:where(&)]:px-(--card-px) [:where(&)]:pt-4 [:where(&)]:pb-3">
<slot name="footer" />
</footer>
</section>
</template>Component API
| Prop | Default | Description |
|---|---|---|
SoftCardrenders a <section> element | ||
title | - | Heading shown at the top of the card. |
description | - | Optional line of context under the title. |
action | - | Rendered opposite the title, a button for example. |
footer | - | Rendered outside the inset body, on the soft background. |
level | 3 | Heading level of the title, to fit the outline of the page. |
dark | false | Keep the dark palette in both color schemes. |
| Prop | Default | Description |
|---|---|---|
SoftCardrenders a <section> element | ||
title | - | Heading shown at the top of the card. |
description | - | Optional line of context under the title. |
level | 3 | Heading level of the title, to fit the outline of the page. |
dark | false | Keep the dark palette in both color schemes. |
#action | - | Slot rendered opposite the title, a button for example. |
#footer | - | Slot rendered outside the inset body, on the soft background. |
#default | - | The inset body of the card. |
Examples
Basic example
The card exposes its paddings as CSS variables. Use --card-section-px and --card-section-py on the body so its content lines up with the title above:
API keys
Keys let a server talk to the API on your behalf.
No key created yet.
import { SoftCard } from '@/components/soft-card'
export default function Example() {
return (
<SoftCard title="API keys" description="Keys let a server talk to the API on your behalf.">
<div className="px-(--card-section-px) py-(--card-section-py)">
<p className="text-sm/6 text-zinc-500">No key created yet.</p>
</div>
</SoftCard>
)
}<script setup lang="ts">
import SoftCard from '@/components/soft-card.vue'
</script>
<template>
<SoftCard title="API keys" description="Keys let a server talk to the API on your behalf.">
<div class="px-(--card-section-px) py-(--card-section-py)">
<p class="text-sm/6 text-zinc-500">No key created yet.</p>
</div>
</SoftCard>
</template>With an action
The action prop sits opposite the title, and wraps under it when the card gets narrow:
API keys
Keys let a server talk to the API on your behalf.
No key created yet.
import { Button } from '@/components/button'
import { SoftCard } from '@/components/soft-card'
export default function Example() {
return (
<SoftCard
title="API keys"
description="Keys let a server talk to the API on your behalf."
action={<Button outline>Create key</Button>}
>
<div className="px-(--card-section-px) py-(--card-section-py)">
<p className="text-sm/6 text-zinc-500">No key created yet.</p>
</div>
</SoftCard>
)
}<template>
<SoftCard title="API keys" description="Keys let a server talk to the API on your behalf.">
<template #action>
<Button outline>Create key</Button>
</template>
<div class="px-(--card-section-px) py-(--card-section-py)">
<p class="text-sm/6 text-zinc-500">No key created yet.</p>
</div>
</SoftCard>
</template>With a footer
The footer prop renders outside the inset body, on the soft background:
Webhook endpoint
We post every event to this URL.
https://api.example.com/hooks
Live<SoftCard
title="Webhook endpoint"
description="We post every event to this URL."
footer={<p className="text-sm/6 text-zinc-500">Last delivery 3 minutes ago.</p>}
>
<div className="px-(--card-section-px) py-(--card-section-py)">
<p className="font-mono text-sm/6">https://api.example.com/hooks</p>
</div>
</SoftCard><template>
<SoftCard title="Webhook endpoint" description="We post every event to this URL.">
<div class="px-(--card-section-px) py-(--card-section-py)">
<p class="font-mono text-sm/6">https://api.example.com/hooks</p>
</div>
<template #footer>
<p class="text-sm/6 text-zinc-500">Last delivery 3 minutes ago.</p>
</template>
</SoftCard>
</template>Adjusting the spacing
Override --card-px at the call site to tighten the whole card. The body follows, since its own paddings are derived from it:
Compact
Same card, tighter gutters.
<SoftCard title="Compact" className="[--card-px:--spacing(4)]">
<div className="px-(--card-section-px) py-4">…</div>
</SoftCard><template>
<SoftCard title="Compact" class="[--card-px:--spacing(4)]">
<div class="px-(--card-section-px) py-4">…</div>
</SoftCard>
</template>