Grain
Grain texture overlay in four treatments: plain noise, paper and sandpaper at overlay blend, and square at soft-light. Intensity steps from fine to rich or takes an exact opacity.
pnpm dlx shadcn add @01orbital/grainimport { cn } from "cn"
import {
PAPER_GRAIN,
SANDPAPER_GRAIN,
SINGLE_NOISE,
SQUARE_GRAIN,
} from "@/components/texture/grain-recipes"
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)
// Four grain treatments; each paints a single texture layer.
// Full-bleed art layer — drop inside a `relative` parent; sits behind content.
/** The texture treatment painted by `Grain`. */
type GrainVariant =
// Stylized paper-look variants — heavier filter chain than plain noise.
"paper" | "sandpaper" | "square" | "single"
type Layer = {
src: string
size: string
opacity: number
blend: "overlay" | "soft-light"
}
const BLEND_CLASS: Record<Layer["blend"], string> = {
overlay: "mix-blend-overlay",
"soft-light": "mix-blend-soft-light",
}
const VARIANT_TEXTURES: Record<GrainVariant, Layer> = {
paper: {
src: PAPER_GRAIN,
size: "250px",
opacity: 0.25,
blend: "overlay",
},
sandpaper: {
src: SANDPAPER_GRAIN,
size: "200px",
opacity: 0.25,
blend: "overlay",
},
square: {
src: SQUARE_GRAIN,
size: "200px",
opacity: 0.8,
blend: "soft-light",
},
single: { src: SINGLE_NOISE, size: "200px", opacity: 0.3, blend: "overlay" },
}
/**
* A decorative grain layer that fills a positioned parent without blocking
* clicks. Choose a texture and adjust its strength with `intensity` or
* `opacity`. `className` and other div props land on the outer layer.
*/
function Grain({
variant = "single",
intensity = "default",
opacity,
isolated = false,
className,
...props
}: React.ComponentProps<"div"> & {
/** The grain treatment: `single` (default), `paper`, `sandpaper` or `square`. */
variant?: GrainVariant
/** Multiplies the texture's opacity: `fine` 0.6×, `default` 1×, `rich` 1.45×. */
intensity?: Intensity
/** The texture opacity, from 0 to 1. Takes precedence over `intensity`. */
opacity?: number
/**
* Adds paint containment when true. This creates a stacking context, so
* the texture stops blending with scene layers outside this element.
*/
isolated?: boolean
}) {
const layer = VARIANT_TEXTURES[variant]
const m =
opacity != null ? opacity / layer.opacity : INTENSITY_MULT[intensity]
return (
<div
data-slot="grain"
data-variant={variant}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0",
isolated && "contain-[paint]",
className
)}
{...props}
>
<div
className={cn("absolute inset-0", BLEND_CLASS[layer.blend])}
style={{
backgroundImage: layer.src,
backgroundSize: layer.size,
opacity: layer.opacity * m,
}}
/>
</div>
)
}
export { Grain }
// The grain tiles the kit paints with, as data URIs, each held once. Every
// recipe is a tiny SVG turbulence filter; the component using it chooses
// the tile size, opacity and blend. `*_URI` is the bare image for callers
// that wrap it themselves; the unsuffixed name is ready for `background-image`.
/** Stylized paper: coarse turbulence pushed to high contrast, the grain under most of the painted scenes and the Grain `paper` variant. */
export const PAPER_GRAIN_URI =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='250' height='250' viewBox='0 0 100 100'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='turbulence' baseFrequency='1.4' numOctaves='1' seed='2' stitchTiles='stitch' result='n'/%3E%3CfeComponentTransfer result='g'%3E%3CfeFuncR type='linear' slope='4' intercept='1'/%3E%3CfeFuncG type='linear' slope='4' intercept='1'/%3E%3CfeFuncB type='linear' slope='4' intercept='1'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix type='saturate' values='0' in='g'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const PAPER_GRAIN = `url("${PAPER_GRAIN_URI}")`
/** Sandpaper: finer, two-octave turbulence, the Grain `sandpaper` variant and the pours and pools. */
export const SANDPAPER_GRAIN_URI =
"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 100 100'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='turbulence' baseFrequency='2.2' numOctaves='2' seed='5' stitchTiles='stitch' result='n'/%3E%3CfeComponentTransfer result='g'%3E%3CfeFuncR type='linear' slope='2.5' intercept='0.8'/%3E%3CfeFuncG type='linear' slope='2.5' intercept='0.8'/%3E%3CfeFuncB type='linear' slope='2.5' intercept='0.8'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix type='saturate' values='0' in='g'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const SANDPAPER_GRAIN = `url("${SANDPAPER_GRAIN_URI}")`
/** Square: three-octave turbulence at high contrast, the Grain `square` variant. */
export const SQUARE_GRAIN_URI =
"data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20width=%22200%22%20height=%22200%22%20viewBox=%220%200%20100%20100%22%3E%3Cfilter%20id=%22n%22%3E%3CfeTurbulence%20type=%22turbulence%22%20baseFrequency=%222.1%22%20numOctaves=%223%22%20seed=%2211%22%20stitchTiles=%22stitch%22%20result=%22n%22/%3E%3CfeComponentTransfer%20result=%22g%22%3E%3CfeFuncR%20type=%22linear%22%20slope=%225.5%22%20intercept=%221.4%22/%3E%3CfeFuncG%20type=%22linear%22%20slope=%225.5%22%20intercept=%221.4%22/%3E%3CfeFuncB%20type=%22linear%22%20slope=%225.5%22%20intercept=%221.4%22/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix%20type=%22saturate%22%20values=%220%22%20in=%22g%22/%3E%3C/filter%3E%3Crect%20width=%22100%%22%20height=%22100%%22%20filter=%22url(%23n)%22/%3E%3C/svg%3E"
export const SQUARE_GRAIN = `url("${SQUARE_GRAIN_URI}")`
/** Plain fractal noise on a 200px tile, the Grain `single` variant. */
export const SINGLE_NOISE_URI =
"data:image/svg+xml,%3Csvg viewBox='0 0 200 200' 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"
export const SINGLE_NOISE = `url("${SINGLE_NOISE_URI}")`
/** Fine fractal noise on a 512px tile, SoftGrain and the Limb scene. */
export const SOFT_NOISE_URI =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const SOFT_NOISE = `url("${SOFT_NOISE_URI}")`
/** Fine fractal noise the field scenes lay over their art: Fracture Field, Gestural Zip, Solar Flare and Underwater Shaft. */
export const FIELD_NOISE_URI =
"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const FIELD_NOISE = `url("${FIELD_NOISE_URI}")`
/** Fine fractal noise on a 256px tile, the mist over the radial charts. */
export const CHART_NOISE_URI =
"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"
export const CHART_NOISE = `url("${CHART_NOISE_URI}")`
/** A one-octave dither on a 128px tile, the glass of the neon signs. */
export const DITHER_NOISE_URI =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E"
export const DITHER_NOISE = `url("${DITHER_NOISE_URI}")`
/** A thin film: paper turbulence with its alpha cut to 0.6, the grain GlowSection lays over its light. */
export const GLOW_FILM_URI =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='250' height='250'%3E%3Cfilter id='sf'%3E%3CfeTurbulence type='turbulence' baseFrequency='1.4' numOctaves='1' seed='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.6'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23sf)'/%3E%3C/svg%3E"
export const GLOW_FILM = `url("${GLOW_FILM_URI}")`
/** Plain fractal noise on a 400px tile, the canvas finish of ColorField and FeltOrb. */
export const CANVAS_NOISE_URI =
"data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"
export const CANVAS_NOISE = `url("${CANVAS_NOISE_URI}")`
import { SideGlow } from "@/components/glow/side-glow"
import { Subheading } from "@/components/subheading/subheading"
import { Grain } from "@/components/texture/grain"
const COPY = {
title: "Night notes",
body: "Written after sundown.",
} as const
export default function GrainDemo() {
return (
<div className="flex min-h-full w-full flex-1 items-center justify-center p-6">
<article className="relative isolate flex aspect-4/3 w-full max-w-96 flex-col justify-end overflow-hidden rounded-3xl bg-[oklch(0.372_0.044_257.3)] p-8 text-scene-foreground">
<SideGlow />
<Grain />
<div className="relative">
<Subheading>{COPY.title}</Subheading>
<p className="mt-3 text-sm/6">{COPY.body}</p>
</div>
</article>
</div>
)
}
"use client"
import { useId } from "react"
import { cn } from "cn"
const EDGE = {
right: {
cx: "100%",
cy: "50%",
className: "top-[-40%] -right-40 h-[180%] w-7xl",
},
left: {
cx: "0%",
cy: "50%",
className: "top-[-40%] -left-40 h-[180%] w-7xl",
},
bottom: {
cx: "50%",
cy: "100%",
className: "-bottom-40 left-[-40%] h-320 w-[180%]",
},
top: {
cx: "50%",
cy: "0%",
className: "-top-40 left-[-40%] h-320 w-[180%]",
},
} as const
/**
* A soft blue glow that comes in from one edge of a section, for the
* background behind its content. Put it inside a positioned parent: it fills
* that parent, clips itself to it, ignores the pointer and is hidden from
* assistive tech.
*/
function SideGlow({
edge = "right",
className,
...props
}: React.ComponentProps<"div"> & {
/** The edge the glow comes in from. Defaults to "right". */
edge?: keyof typeof EDGE
}) {
// An id passed in names the root; the gradient keeps its own.
const gradientId = `side-glow-${useId().replace(/[^\w-]/g, "")}`
const anchor = EDGE[edge]
return (
<div
aria-hidden="true"
data-slot="side-glow"
className={cn(
"pointer-events-none absolute inset-0 overflow-hidden",
className
)}
{...props}
>
<svg
className={cn("absolute", anchor.className)}
preserveAspectRatio="none"
>
<defs>
<radialGradient id={gradientId} cx={anchor.cx} cy={anchor.cy}>
<stop offset="0%" stopColor="oklch(0.754 0.139 233 / 0.3)" />
<stop offset="53.95%" stopColor="oklch(0.511 0.274 264 / 0.09)" />
<stop offset="100%" stopColor="oklch(0.164 0.02 266 / 0)" />
</radialGradient>
</defs>
<rect width="100%" height="100%" fill={`url(#${gradientId})`} />
</svg>
</div>
)
}
/**
* A one-pixel vertical line of white, blended in overlay mode so it lightens
* the surface under it. It is decorative and hidden from assistive tech.
*
* It is absolutely positioned with no height of its own: place it with
* `className`, for example `inset-y-0 right-0`.
*/
function SeamLine({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
aria-hidden="true"
data-slot="seam-line"
className={cn(
"pointer-events-none absolute w-px bg-[oklch(1_0_0)] mix-blend-overlay",
className
)}
{...props}
/>
)
}
export { SideGlow, SeamLine }
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "cn"
// No width of its own: the SectionHeader column (or whatever holds it) is
// the measure. text-pretty keeps a wrapped title from ending on one word.
// font-display follows font-sans unless the block switches it, for example
// with [--font-display:var(--font-serif)].
/**
* The section heading in the display font, rendered as an `<h2>`: 30px with
* a 36px line, 40px with a 40px line from the `sm` breakpoint. One size, no
* props beyond the element's own; pass `render` to use another heading
* level. A page's headline is `Heading`.
*/
function Subheading({
className,
render,
...props
}: useRender.ComponentProps<"h2">) {
return useRender({
defaultTagName: "h2",
render,
state: { slot: "subheading" },
props: mergeProps<"h2">(
{
className: cn(
"font-display text-3xl/9 tracking-[-0.03em] text-pretty sm:text-[2.5rem]/10",
className
),
},
props
),
})
}
export { Subheading }
The example also uses side-glow and subheading. Install them too:
pnpm dlx shadcn add @01orbital/side-glow @01orbital/subheadingnpx shadcn add @01orbital/side-glow @01orbital/subheadingyarn shadcn add @01orbital/side-glow @01orbital/subheadingbunx --bun shadcn add @01orbital/side-glow @01orbital/subheadingUsage
import { SideGlow } from "@/components/glow/side-glow"
import { Subheading } from "@/components/subheading/subheading"
import { Grain } from "@/components/texture/grain"
const COPY = {
title: "Night notes",
body: "Written after sundown.",
} as const
export default function GrainDemo() {
return (
<div className="flex min-h-full w-full flex-1 items-center justify-center p-6">
<article className="relative isolate flex aspect-4/3 w-full max-w-96 flex-col justify-end overflow-hidden rounded-3xl bg-[oklch(0.372_0.044_257.3)] p-8 text-scene-foreground">
<SideGlow />
<Grain />
<div className="relative">
<Subheading>{COPY.title}</Subheading>
<p className="mt-3 text-sm/6">{COPY.body}</p>
</div>
</article>
</div>
)
}Examples
New
grain-bakery-slip
API reference
A decorative grain layer that fills a positioned parent without blocking clicks. Choose a texture and adjust its strength with intensity or opacity. className and other div props land on the outer layer.
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.