Teal painted panel whose paint wears away on hover to expose a pencil construction sketch, with the label switching to canvas ink. Set in Marcellus; decorative, and reduced motion skips the fade.
// A scratch-off panel: hover wears the paint away to the construction sketch
// underneath. Decoration, not a control; reduced motion skips the fade.
import { cn } from "cn"
const paint = {
tealBase: "oklch(0.52 0.10 180)",
tealDark: "oklch(0.44 0.09 180)",
canvas: "oklch(0.94 0.012 85)",
sketchLine: "oklch(0.82 0 0)",
sketchArc: "oklch(0.77 0 0)",
sketchText: "oklch(0.70 0 0)",
// The revealed label sits on the canvas, so it takes a canvas ink (15:1).
canvasInk: "oklch(0.22 0.02 85)",
} as const
const canvasTexture = `url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`
type ScratchRevealProps = React.ComponentProps<"div">
/**
* A teal painted panel, 240 by 100px by default, with its children as a
* label in Marcellus. On hover the paint wears away to a pencil construction
* sketch and the label switches to a dark canvas ink.
*
* Decoration, not a control: there is no focus state, and reduced motion
* swaps without the fade. The label renders twice, once hidden from
* assistive tech, so keep it to plain text.
*/
function ScratchReveal({
className,
children,
style,
...props
}: ScratchRevealProps) {
return (
<div
data-slot="scratch-reveal"
className={cn(
"group/scratch-reveal relative h-25 w-60 shrink-0 overflow-hidden rounded",
className
)}
style={{ background: paint.canvas, ...style }}
{...props}
>
{/* Sketch layer — always present underneath, exposed by the reveal */}
<svg
viewBox="0 0 240 100"
className="absolute inset-0 size-full"
aria-hidden="true"
>
<line
x1="80"
y1="0"
x2="80"
y2="100"
stroke={paint.sketchLine}
strokeWidth="0.5"
strokeDasharray="2 4"
/>
<line
x1="160"
y1="0"
x2="160"
y2="100"
stroke={paint.sketchLine}
strokeWidth="0.5"
strokeDasharray="2 4"
/>
<line
x1="0"
y1="50"
x2="240"
y2="50"
stroke={paint.sketchLine}
strokeWidth="0.5"
strokeDasharray="2 4"
/>
<path
d="M 40 80 A 60 60 0 0 1 200 80"
fill="none"
stroke={paint.sketchArc}
strokeWidth="0.8"
strokeDasharray="3 3"
/>
{/* Painter's notes (decorative marks, not UI text) */}
<text
x="15"
y="18"
fontSize="8"
fill={paint.sketchText}
fontFamily="Courier, monospace"
>
1/3
</text>
<text
x="175"
y="95"
fontSize="7"
fill={paint.sketchText}
fontFamily="system-ui, sans-serif"
>
golden ratio?
</text>
</svg>
{/* Paint surface — wears to 8% on hover to expose the sketch */}
<span
data-slot="scratch-reveal-paint"
className={cn(
"absolute inset-0 opacity-100",
"group-hover/scratch-reveal:opacity-[0.08]",
"motion-safe:transition-opacity motion-safe:duration-500 motion-safe:ease-out"
)}
style={{
background: `linear-gradient(140deg, ${paint.tealBase} 0%, ${paint.tealDark} 100%)`,
}}
aria-hidden="true"
>
<span
className="absolute inset-0 opacity-12 mix-blend-multiply"
style={{ backgroundImage: canvasTexture }}
/>
</span>
<span
className={cn(
"absolute inset-0 z-10 flex items-center justify-center",
"font-marcellus text-base text-scene-foreground",
"group-hover/scratch-reveal:opacity-0",
"motion-safe:transition-opacity motion-safe:duration-300"
)}
>
{children}
</span>
<span
className={cn(
"absolute inset-0 z-10 flex items-center justify-center",
"font-marcellus text-base opacity-0",
"group-hover/scratch-reveal:opacity-100",
"motion-safe:transition-opacity motion-safe:duration-300"
)}
style={{ color: paint.canvasInk }}
aria-hidden="true"
>
{children}
</span>
</div>
)
}
export { ScratchReveal }
export type { ScratchRevealProps }
pnpm dlx shadcn add @01orbital/scratch-revealnpx shadcn add @01orbital/scratch-revealyarn shadcn add @01orbital/scratch-revealbunx --bun shadcn add @01orbital/scratch-revealFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/graphics/scratch-reveal.tsx, as source you own and edit.
import { ScratchReveal } from "@/components/graphics/scratch-reveal"
// Every word on the page. Change copy here, not in the markup.
const COPY = {
label: "Under the surface",
} as const
export default function ScratchRevealDemo() {
return <ScratchReveal>{COPY.label}</ScratchReveal>
}It accepts the HTML attributes of the element it renders.
npm: cn. The CLI installs them for you.
Registry pieces it installs with it:
font-marcellusRoad Signs
North American road sign set: highway guide, exit, next services, mile marker, speed limit and warning diamond on green, blue and yellow faces. Uses the Overpass sign font and Phosphor service icons.
Sparkline
Compact SVG line chart from an array of numbers, with a currentColor stroke and fading area fill. Missing values break the line, one point draws a dot, and an aria-label exposes it as an image.