Vellum
Vellum texture: a sheet layer combining two turbulence scales, a crosshatch of near-parallel fibers and a soft wash. Warm grounds read brass and cool ones silver; three intensities scale grain and fibers.
pnpm dlx shadcn add @01orbital/vellumimport { cn } from "cn"
const GRAIN_FINE = `url("data:image/svg+xml,%3Csvg viewBox='0 0 128 128' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='g3'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='2' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g3)'/%3E%3C/svg%3E")`
const GRAIN_MEDIUM = `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='g2'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g2)'/%3E%3C/svg%3E")`
type Intensity = "fine" | "default" | "rich"
// One scale for every texture; `default` is the unscaled value.
const INTENSITY_MULT: Record<Intensity, number> = {
fine: 0.6,
default: 1,
rich: 1.45,
}
const alpha = (base: number, mult: number) => (base * mult).toFixed(4)
// Two turbulence scales, a crosshatch of near-parallel rules, and a wash.
// Warm grounds read brass, cool read silver-tarnish.
type BlendMode = "soft-light" | "overlay" | "multiply" | "screen" | "normal"
const BLEND_CLASS: Record<BlendMode, string> = {
"soft-light": "mix-blend-soft-light",
overlay: "mix-blend-overlay",
multiply: "mix-blend-multiply",
screen: "mix-blend-screen",
normal: "",
}
type Fiber = {
// Degrees from horizontal. Pure 0°/90° aliases against pixel grid (moiré);
// values just off-axis (1.5°, -2°, 89°, 91°) break that regularity.
angle: number
// Transparent gap before the line; full repeat = spacing + 1px.
spacing: number
// Line color alpha at start and end of its 1px transition.
alphas: [number, number]
// White or black ink. White fibers read as raised paper; black as recessed.
channel: "white" | "black"
}
type Layer =
| { kind: "wash"; color: string; blend?: BlendMode }
| {
kind: "noise"
src: string
size: number
opacity: number
blend: BlendMode
}
| { kind: "fibers"; lines: [Fiber, Fiber]; blend: BlendMode }
const SOFT_FIBERS: Layer = {
kind: "fibers",
blend: "overlay",
lines: [
{ angle: 1.5, spacing: 10, alphas: [0.02, 0.032], channel: "white" },
{ angle: -2, spacing: 12, alphas: [0.018, 0.028], channel: "black" },
],
}
const LIGHT_LAYERS: Layer[] = [
{
kind: "noise",
src: GRAIN_FINE,
size: 56,
opacity: 0.14,
blend: "soft-light",
},
{
kind: "noise",
src: GRAIN_MEDIUM,
size: 84,
opacity: 0.1,
blend: "overlay",
},
SOFT_FIBERS,
{ kind: "wash", color: "oklch(0.97 0.008 88 / 0.11)", blend: "soft-light" },
]
function renderFibers(lines: readonly Fiber[], mult: number): string {
return lines
.map((f) => {
const channel = f.channel === "white" ? "1" : "0"
const a1 = (f.alphas[0] * mult).toFixed(4)
const a2 = (f.alphas[1] * mult).toFixed(4)
return `repeating-linear-gradient(${f.angle}deg in srgb, transparent 0px, transparent ${f.spacing}px, oklch(${channel} 0 0 / ${a1}) ${f.spacing}px, oklch(${channel} 0 0 / ${a2}) ${f.spacing + 1}px)`
})
.join(", ")
}
function renderLayer(layer: Layer, mult: number, key: number) {
if (layer.kind === "wash") {
return (
<div
key={key}
className={cn(
"absolute inset-0",
layer.blend && BLEND_CLASS[layer.blend]
)}
style={{ background: layer.color }}
/>
)
}
if (layer.kind === "noise") {
return (
<div
key={key}
className={cn("absolute inset-0", BLEND_CLASS[layer.blend])}
style={{
backgroundImage: layer.src,
backgroundSize: `${layer.size}px`,
opacity: layer.opacity * mult,
}}
/>
)
}
return (
<div
key={key}
className={cn("absolute inset-0", BLEND_CLASS[layer.blend])}
style={{ backgroundImage: renderFibers(layer.lines, mult) }}
/>
)
}
// Full-bleed art layer — drop inside a `relative` parent; sits behind content.
// One sheet, three intensities via `intensity`.
function Vellum({
intensity = "default",
className,
...props
}: React.ComponentProps<"div"> & {
// Scales noise + fiber alphas. `fine` whispers, `rich` is assertive.
intensity?: Intensity
}) {
const mult = INTENSITY_MULT[intensity]
return (
<div
className={cn("pointer-events-none absolute inset-0", className)}
aria-hidden="true"
data-slot="vellum"
{...props}
>
{LIGHT_LAYERS.map((layer, i) => renderLayer(layer, mult, i))}
</div>
)
}
export { Vellum }
import { Vellum } from "@/components/texture/vellum"
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
/** A sized vellum surface with readable studio details. */
export default function VellumDemo() {
return (
<div className="flex min-h-full w-full flex-1 items-center justify-center p-4 sm:p-8">
<Card className="relative isolate w-full max-w-sm gap-6 overflow-hidden rounded-xl bg-[#d6c9a4] p-6 wrap-anywhere text-[#383429] shadow-sm ring-1 ring-black/10 [--muted-foreground:#494330] sm:p-8">
<Vellum intensity="rich" />
<CardHeader className="relative z-20 gap-6 px-0">
<p className="text-sm font-medium">Aster Archive</p>
<div className="grid gap-1">
<CardTitle className="text-2xl/8 font-medium tracking-tight">
Freya Bell
</CardTitle>
<CardDescription>Archivist & book artist</CardDescription>
</div>
</CardHeader>
<CardFooter className="relative z-20 flex-wrap justify-between gap-3 border-t border-current/20 px-0 pt-5 text-xs/5">
<span>42 Museum Street, London</span>
<a
href="mailto:hello@asterarchive.example"
className="break-all underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
>
hello@asterarchive.example
</a>
</CardFooter>
</Card>
</div>
)
}
The example also uses card. Install it too:
pnpm dlx shadcn add @01orbital/cardnpx shadcn add @01orbital/cardyarn shadcn add @01orbital/cardbunx --bun shadcn add @01orbital/cardUsage
import { Vellum } from "@/components/texture/vellum"
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
/** A sized vellum surface with readable studio details. */
export default function VellumDemo() {
return (
<div className="flex min-h-full w-full flex-1 items-center justify-center p-4 sm:p-8">
<Card className="relative isolate w-full max-w-sm gap-6 overflow-hidden rounded-xl bg-[#d6c9a4] p-6 wrap-anywhere text-[#383429] shadow-sm ring-1 ring-black/10 [--muted-foreground:#494330] sm:p-8">
<Vellum intensity="rich" />
<CardHeader className="relative z-20 gap-6 px-0">
<p className="text-sm font-medium">Aster Archive</p>
<div className="grid gap-1">
<CardTitle className="text-2xl/8 font-medium tracking-tight">
Freya Bell
</CardTitle>
<CardDescription>Archivist & book artist</CardDescription>
</div>
</CardHeader>
<CardFooter className="relative z-20 flex-wrap justify-between gap-3 border-t border-current/20 px-0 pt-5 text-xs/5">
<span>42 Museum Street, London</span>
<a
href="mailto:hello@asterarchive.example"
className="break-all underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
>
hello@asterarchive.example
</a>
</CardFooter>
</Card>
</div>
)
}Examples
New
vellum-studio-card
New
vellum-workshop-invitation
New
vellum-seasonal-menu
New
vellum-field-note
New
vellum-edition-cover
New
vellum-order-receipt
New
vellum-collection-label
New
vellum-studio-letter
New
vellum-quote-card
New
vellum-day-programme
API reference
Prop
Type
It also accepts the HTML attributes of the element it renders.
Dependencies
npm: cn. The CLI installs them for you.
No other registry pieces.