← Dashboard
Zubeyr··Componentschartscards

Card

A dashboard card with a dotted-underline title, headline value with trend, an action slot and a legend.

Live demo ↗

Installation

Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.

pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/card.json
  • →Files land in components/dashboard.

Usage

example.tsx
import { Card, LegendItem } from '@/components/dashboard/card'

export default function Demo() {
  return (
    <Card
      title="Total sales"
      value="$250,000"
      change={42}
      className="w-full max-w-md"
      legend={
        <>
          <LegendItem color="var(--chart-1)">This period</LegendItem>
          <LegendItem color="var(--chart-2)" dashed>
            Previous period
          </LegendItem>
        </>
      }
    >
      <div className="grid h-40 place-items-center rounded-lg bg-muted text-sm text-muted-foreground">
        Your chart here
      </div>
    </Card>
  )
}

Source code

card.tsx
import { cn } from '@/lib/utils'

type CardProps = {
  title: React.ReactNode
  value?: React.ReactNode
  change?: number
  action?: React.ReactNode
  legend?: React.ReactNode
  className?: string
  children: React.ReactNode
}

export function Card({ title, value, change, action, legend, className, children }: CardProps) {
  return (
    <article
      className={cn(
        'flex flex-col gap-3 rounded-xl bg-card p-4 text-card-foreground shadow-(--shadow-card)',
        className,
      )}
    >
      <header className="flex items-start justify-between gap-2">
        <h3 className="text-sm font-semibold underline decoration-muted-foreground/50 decoration-dotted decoration-2 underline-offset-4">
          {title}
        </h3>
        {action}
      </header>
      {value !== undefined && (
        <div className="flex items-baseline gap-2">
          <span className="text-2xl font-bold tracking-tight tabular-nums">{value}</span>
          {change !== undefined && <Trend value={change} />}
        </div>
      )}
      <div className="min-h-0 flex-1">{children}</div>
      {legend && (
        <footer className="flex flex-wrap justify-center gap-4 text-xs text-muted-foreground">
          {legend}
        </footer>
      )}
    </article>
  )
}

export function Trend({ value, className }: { value: number; className?: string }) {
  const up = value >= 0
  return (
    <span
      className={cn(
        'inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums',
        up ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
        className,
      )}
    >
      <span aria-hidden>{up ? '↗' : '↘'}</span>
      <span className="sr-only">{up ? 'Up' : 'Down'}</span>
      {Math.abs(value)}%
    </span>
  )
}

export function LegendItem({
  color,
  dashed = false,
  children,
}: {
  color: string
  dashed?: boolean
  children: React.ReactNode
}) {
  return (
    <span className="inline-flex items-center gap-1.5">
      <svg width="16" height="4" aria-hidden>
        <line
          x1="2"
          y1="2"
          x2="14"
          y2="2"
          stroke={color}
          strokeWidth="2.5"
          strokeLinecap="round"
          strokeDasharray={dashed ? '0.5 4' : undefined}
        />
      </svg>
      {children}
    </span>
  )
}