Flader
Notch card
Et prøvekort med et hak skåret ud af det ene hjørne; billedet går fra gråt til farve ved hover eller fokus, med én ubrudt hårstreg.
Forhåndsvisning
Brug
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>
)
}Kilde
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>
)
}Installér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/notch-card.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/notch-card