Mikkel Manniche
Dansk English Deutsch

Flächen

Notch card

Eine Musterkarte mit einer Kerbe in einer Ecke; ihr Bild wechselt bei Hover oder Fokus von Grau zu Farbe, mit einer durchgehenden Haarlinie.

Vorschau

Verwendung

notch-card-demo.tsx
import { useState } from 'react'
import { NotchCard } from '@/components/notch-card'

// A small seeded random generator, so the drawing is the same on every render and on the server.
function rng(seed: number) {
  return () => {
    seed |= 0
    seed = (seed + 0x6d2b79f5) | 0
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

// A closed curve through the midpoints of the given points.
function smooth(pts: [number, number][]) {
  const n = pts.length
  const mid = (a: [number, number], b: [number, number]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
  const s = mid(pts[n - 1], pts[0])
  let d = `M${s[0].toFixed(1)} ${s[1].toFixed(1)}`
  for (let i = 0; i < n; i++) {
    const p = pts[i]
    const q = mid(p, pts[(i + 1) % n])
    d += `Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${q[0].toFixed(1)} ${q[1].toFixed(1)}`
  }
  return d + 'Z'
}

// The agate's bands, mixed from the theme tokens.
const fills = {
  a0: 'fill-[color-mix(in_oklab,var(--primary)_80%,var(--card))]',
  a1: 'fill-[color-mix(in_oklab,var(--primary)_32%,var(--card))]',
  a2: 'fill-[color-mix(in_oklab,var(--success,var(--primary))_58%,var(--card))]',
  a3: 'fill-[color-mix(in_oklab,var(--card)_92%,var(--foreground))]',
  a4: 'fill-[color-mix(in_oklab,var(--destructive)_55%,var(--card))]',
  a5: 'fill-[color-mix(in_oklab,var(--primary)_55%,var(--foreground))]',
  a6: 'fill-accent',
} as const
const order = ['a5', 'a1', 'a0', 'a3', 'a2', 'a1', 'a4', 'a3', 'a0', 'a6', 'a5', 'a1', 'a3', 'a0', 'a2', 'a3', 'a6', 'a3'] as const

const W = 280
const H = 214
const bands = (() => {
  const rand = rng(11)
  const harm = [2, 3, 5, 7].map((f, j) => ({ f, a: (0.085 / (j + 1)) * (0.7 + rand() * 0.6), p: rand() * 6.283 }))
  const cx = W * 0.44
  const cy = H * 0.58
  return order.map((fill, k) => {
    const t = k / order.length
    const r0 = 190 * Math.pow(1 - t, 1.25) + 5
    const pts: [number, number][] = []
    for (let s = 0; s < 120; s++) {
      const th = (s / 120) * Math.PI * 2
      let m = 1
      harm.forEach((q, j) => {
        m += q.a * Math.sin(q.f * th + q.p + t * (j % 2 ? 0.8 : -0.6))
      })
      m += Math.sin(th * 13 + k) * 0.006
      pts.push([cx + Math.cos(th) * r0 * m * 1.1, cy + Math.sin(th) * r0 * m * 0.82])
    }
    return { fill, d: smooth(pts) }
  })
})()

// A cross-section of an agate, drawn in SVG from the theme tokens.
function Agate() {
  return (
    <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid slice" aria-hidden>
      <rect width={W} height={H} className="fill-muted" />
      {bands.map((b, k) => (
        <path key={k} d={b.d} className={`${fills[b.fill]} stroke-foreground/14 [stroke-width:0.6]`} />
      ))}
    </svg>
  )
}

export default function NotchCardDemo() {
  // A tap or click holds the colour, so it can be seen on touch screens too.
  const [held, setHeld] = useState(false)
  return (
    <div className="grid place-items-center py-4">
      <NotchCard
        className="min-h-96"
        image={<Agate />}
        eyebrow="specimen 05"
        title="Agate, cross-section"
        href="#notch-card"
        linkProps={{
          onClick: (e) => {
            e.preventDefault()
            setHeld((v) => !v)
          },
        }}
        bloom={held}
        footer={
          <>
            <span>drawn · demo</span>
            <span>tokens only</span>
          </>
        }
      >
        Drawn in SVG from the theme tokens. Grey at rest, in colour on hover or focus.
      </NotchCard>
    </div>
  )
}

Quellcode

notch-card.tsx
import { useEffect, useLayoutEffect, useRef, type AnchorHTMLAttributes, type HTMLAttributes, type ReactNode } from 'react'
import { ArrowUpRight } from 'lucide-react'
import { cn } from '@/lib/utils'

export type NotchCardProps = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
  /** The picture at the top: an img or an SVG. It fills the frame and is shown in grey until it blooms. */
  image: ReactNode
  /** The card's title. With `href` it is the link, stretched over the whole card. */
  title: ReactNode
  /** A small mono line above the title. */
  eyebrow?: ReactNode
  /** A short caption under the title. */
  children?: ReactNode
  /** A row at the bottom, under a hairline, e.g. two small facts. */
  footer?: ReactNode
  /** Makes the whole card a link. */
  href?: string
  /** Extra attributes for the link, e.g. `target` or `onClick`. */
  linkProps?: Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'children'>
  /** What sits in the notch. Pass your own button for a second action; with `href` the default is an arrow. */
  action?: ReactNode
  /** Which top corner the notch is cut from. */
  notch?: 'top-right' | 'top-left'
  /** Controlled: show the image in colour, e.g. for a selected card. */
  bloom?: boolean
}

const R = 26 // corner radius, the tile radius
const N = 56 // notch size
const r = 14 // radius where the notch turns, the inner radius

// The card's outline with the notch cut from a top corner, in pixels. The left one is the right one mirrored.
function outline(W: number, H: number, left: boolean) {
  if (left)
    return `M${W - R} 0H${N + r}A${r} ${r} 0 0 0 ${N} ${r}V${N - r}A${r} ${r} 0 0 1 ${N - r} ${N}H${R}A${R} ${R} 0 0 0 0 ${N + R}V${H - R}A${R} ${R} 0 0 0 ${R} ${H}H${W - R}A${R} ${R} 0 0 0 ${W} ${H - R}V${R}A${R} ${R} 0 0 0 ${W - R} 0Z`
  return `M${R} 0H${W - N - r}A${r} ${r} 0 0 1 ${W - N} ${r}V${N - r}A${r} ${r} 0 0 0 ${W - N + r} ${N}H${W - R}A${R} ${R} 0 0 1 ${W} ${N + R}V${H - R}A${R} ${R} 0 0 1 ${W - R} ${H}H${R}A${R} ${R} 0 0 1 0 ${H - R}V${R}A${R} ${R} 0 0 1 ${R} 0Z`
}

const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect

/**
 * A card with a notch cut from one top corner, like a punched specimen card; the notch holds its action.
 * The image is grey at rest and blooms into colour on hover or keyboard focus. Only filter and transform animate.
 * The outline is measured once per resize and drawn as one path, so the hairline runs unbroken round the notch.
 * Under reduced motion the image switches to colour without a fade.
 */
export function NotchCard({
  image,
  title,
  eyebrow,
  children,
  footer,
  href,
  linkProps,
  action,
  notch = 'top-right',
  bloom,
  className,
  style,
  ...rest
}: NotchCardProps) {
  const root = useRef<HTMLDivElement>(null)
  const frame = useRef<SVGSVGElement>(null)
  const left = notch === 'top-left'

  // The clip and the hairline share one path, sized to the card. Nothing about it animates.
  useIsoLayoutEffect(() => {
    const el = root.current
    const svg = frame.current
    if (!el || !svg) return
    const layout = () => {
      const W = el.offsetWidth
      const H = el.offsetHeight
      if (!W || !H) return
      const d = outline(W, H, left)
      el.style.setProperty('--nc-clip', `path('${d}')`)
      svg.setAttribute('viewBox', `0 0 ${W} ${H}`)
      svg.querySelectorAll('path').forEach((p) => p.setAttribute('d', d))
    }
    layout()
    const ro = new ResizeObserver(layout)
    ro.observe(el)
    return () => ro.disconnect()
  }, [left])

  const arrow = (
    <span aria-hidden className="grid size-11 place-items-center rounded-full bg-muted text-foreground">
      <ArrowUpRight
        strokeWidth={2}
        className={cn(
          'size-4 transition-transform duration-200 ease-out-quint motion-reduce:transition-none',
          'group-hover/nc:translate-x-0.5 group-hover/nc:-translate-y-0.5 group-has-focus-visible/nc:translate-x-0.5 group-has-focus-visible/nc:-translate-y-0.5',
          'group-data-bloom/nc:translate-x-0.5 group-data-bloom/nc:-translate-y-0.5',
        )}
      />
    </span>
  )
  const inNotch = action ?? (href ? arrow : null)

  return (
    <div
      ref={root}
      data-bloom={bloom ? '' : undefined}
      style={style}
      className={cn('group/nc relative isolate flex w-full max-w-[280px] flex-col text-card-foreground', className)}
      {...rest}
    >
      <div className="flex flex-1 flex-col bg-card [clip-path:var(--nc-clip)]">
        <div className="relative aspect-[280/214] shrink-0 overflow-hidden bg-muted">
          <div
            className={cn(
              'absolute inset-0 scale-[1.001] [filter:grayscale(1)_contrast(0.92)_brightness(1.02)] transition-[filter,scale] duration-280 ease-out-quint motion-reduce:transition-none',
              '[&>img]:size-full [&>img]:object-cover [&>svg]:size-full',
              'group-hover/nc:scale-[1.03] group-hover/nc:[filter:none] group-has-focus-visible/nc:scale-[1.03] group-has-focus-visible/nc:[filter:none]',
              'group-data-bloom/nc:scale-[1.03] group-data-bloom/nc:[filter:none]',
            )}
          >
            {image}
          </div>
        </div>
        <div className="grid gap-2 px-6 pt-5 pb-4">
          {eyebrow && <p className="font-mono text-[11px] leading-none font-medium tracking-[0.04em] text-muted-foreground tabular-nums">{eyebrow}</p>}
          <h3 className="text-[19px] leading-[1.2] font-semibold tracking-[-0.02em] text-pretty">
            {href ? (
              <a
                href={href}
                {...linkProps}
                className={cn(
                  'outline-none after:absolute after:inset-0 after:z-10 after:[clip-path:var(--nc-clip)] focus-visible:outline-none',
                  linkProps?.className,
                )}
              >
                {title}
              </a>
            ) : (
              title
            )}
          </h3>
          {children && <div className="text-[13.5px] leading-normal text-pretty text-muted-foreground">{children}</div>}
        </div>
        {footer && (
          <div className="mx-6 mt-auto mb-[18px] flex justify-between gap-3 border-t border-[color:color-mix(in_oklab,var(--foreground)_9%,transparent)] pt-3.5 font-mono text-[11.5px] leading-none text-muted-foreground tabular-nums">
            {footer}
          </div>
        )}
      </div>
      <svg ref={frame} aria-hidden className="pointer-events-none absolute inset-0 z-20 size-full overflow-visible">
        <path className="fill-none stroke-[color:color-mix(in_oklab,var(--foreground)_9%,transparent)] [stroke-width:1] [vector-effect:non-scaling-stroke]" />
        <path className="fill-none stroke-ring opacity-0 [stroke-width:2] [vector-effect:non-scaling-stroke] group-has-focus-visible/nc:opacity-100" />
      </svg>
      {inNotch && (
        <div className={cn('absolute top-0 z-30 grid size-14 place-items-center', left ? 'left-0' : 'right-0', !action && 'pointer-events-none')}>
          {inNotch}
        </div>
      )}
    </div>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/notch-card.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/notch-card

Abhängigkeiten

Dateien

  • notch-card.tsx

Manniche Lab · Open Source

Kostenlose Werkzeuge für KI-Agenten und Webentwicklung.