Graphics

Scratch Reveal

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.

Free, MIT licenseOpen in Browse
// 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 }

Installation

pnpm dlx shadcn add @01orbital/scratch-reveal
npx shadcn add @01orbital/scratch-reveal
yarn shadcn add @01orbital/scratch-reveal
bunx --bun shadcn add @01orbital/scratch-reveal

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

Usage

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

API reference

It accepts the HTML attributes of the element it renders.

Dependencies

npm: cn. The CLI installs them for you.

Registry pieces it installs with it:

  • font-marcellus