Skip to content
  1. Page structure
  2. Soft card

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

PropDefaultDescription
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.
level3Heading level of the title, to fit the outline of the page.
darkfalseKeep the dark palette in both color schemes.
PropDefaultDescription
SoftCardrenders a <section> element
title-Heading shown at the top of the card.
description-Optional line of context under the title.
level3Heading level of the title, to fit the outline of the page.
darkfalseKeep 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>

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

Last delivery 3 minutes ago.

<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>