08 / 20
All 20 in 20 Tailwind CSS Grain Texture
- 01Tailwind SVG feTurbulence Noise Parameter Breakdown
- 02Tailwind baseFrequency and numOctaves Grain Size Control
- 03Tailwind Data URI SVG Noise Texture Without an Image Request
- 04Tailwind Grain Opacity and mix-blend-mode Composite Matrix
- 05Tailwind Tinted SVG Noise with feColorMatrix
- 06Tailwind Grain Paint Cost and Byte Size Measured Live
- 07Tailwind Anti-Banding Grainy CSS Gradient Hero
- 08Tailwind Vignette Photo Overlay with Film Grain
- 09Tailwind Tactile Card Surface with Subtle Noise
- 10Tailwind Full-Bleed Hero Section Noise Background
- 11Tailwind Noise Layer Order Over backdrop-filter Glass
- 12Tailwind Textured Button and Input Surfaces
- 13Tailwind Textured Section Divider with SVG Grain
- 14Tailwind Static Halftone Dot Print Effect
- 15Tailwind Dithered Bayer Matrix Shading Effect
- 16Tailwind Paper Stock and Letterpress Press Effect
- 17Tailwind Risograph Print Misregistration Texture
- 18Tailwind Woven Fabric and Canvas Texture Surface
- 19Tailwind Dark Mode Noise Without Muddy Shadows
- 20Tailwind Reduced Motion Static Grain Fallback
Tailwind Vignette Photo Overlay with Film Grain
A contact sheet of three frames, each with a lens vignette under a luminosity-blended grain layer and its own grain weight. No canvas, no image processing, no filters on the photograph — four ordinary layers over an <img>. The port's neat trick: the per-frame weight that needed an inline style attribute is now an arbitrary-property utility, [--gtw08-weight:0.13].
Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time
Untitled UI React · Affiliate link
The code
<section class="gtw-08 relative block w-full min-h-svh bg-gtw08-bg text-gtw08-ink" aria-labelledby="gtw-08-title">
<div class="absolute top-[clamp(1rem,3vw,1.75rem)] right-[clamp(0.85rem,3vw,1.75rem)] z-40">
<input type="checkbox" id="gtw-08-theme" class="gtw-08-theme peer sr-only">
<label for="gtw-08-theme" class="grid place-items-center w-11 h-11 rounded-full border border-gtw08-line bg-gtw08-surf text-gtw08-ink cursor-pointer transition-colors duration-200 hover:border-gtw08-acc peer-focus-visible:outline-2 peer-focus-visible:outline-gtw08-acc peer-focus-visible:outline-offset-[3px]">
<svg viewBox="0 0 24 24" class="w-5 h-5" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="sr-only">Toggle dark theme</span>
</label>
</div>
<main class="w-[min(78rem,calc(100%-clamp(1.5rem,5vw,4rem)))] mx-auto pt-[clamp(3rem,8vw,5.5rem)] pb-[clamp(2.5rem,6vw,4rem)] grid gap-[clamp(2rem,5vw,3.2rem)]">
<header class="max-w-[46rem] grid gap-[0.6rem]">
<p class="m-0 font-gtw08-mono text-[0.74rem] leading-none tracking-[0.16em] uppercase text-gtw08-acc">Roll 04 · 400 ISO · pushed one stop</p>
<h1 id="gtw-08-title" class="m-0 font-gtw08-display text-[clamp(2.6rem,8vw,5rem)] font-normal leading-[0.95] tracking-[-0.02em]">Contact sheet</h1>
<p class="m-0 max-w-[38rem] text-gtw08-mut text-[clamp(0.95rem,1.9vw,1.08rem)] text-pretty">Vignette below the grain, grain in <code class="font-gtw08-mono text-[0.9em]">luminosity</code>, weight set per frame with <code class="font-gtw08-mono text-[0.9em]">[--gtw08-weight:…]</code>. No filters, no canvas, no image processing — four layers of CSS over an ordinary <code class="font-gtw08-mono text-[0.9em]"><img></code>.</p>
</header>
<div class="grid gap-[clamp(1rem,2.5vw,1.6rem)] grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))] items-start">
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.13]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1015/1200/1500" alt="A river running between steep rock walls under an overcast sky" loading="lazy" width="1200" height="1500" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">01 · Gorge</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Bright, flat frame. Grain pushed to 0.13 to give the sky some tooth.</span>
</figcaption>
</figure>
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.07]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1039/1600/1000" alt="A waterfall seen from above, surrounded by dense forest" loading="lazy" width="1600" height="1000" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">02 · Falls</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Dark and contrasty. Grain held at 0.07 — any more and the shadows turn to mud.</span>
</figcaption>
</figure>
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.1]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1043/1200/1500" alt="A person standing on a rock outcrop looking across a valley" loading="lazy" width="1200" height="1500" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">03 · Outcrop</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Falloff kept outside the figure so the vignette reads as lens, not retouching.</span>
</figcaption>
</figure>
</div>
<aside class="grid gap-[0.9rem] p-[clamp(1.2rem,3vw,1.9rem)] rounded-[14px] border border-gtw08-line bg-gtw08-surf">
<h2 class="m-0 font-gtw08-display text-[clamp(1.3rem,3vw,1.7rem)] font-normal leading-[1.15]">Layer order, bottom to top</h2>
<ol class="gtw-08-stack list-none m-0 p-0 grid gap-[0.1rem]">
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Photograph</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">object-cover, aspect-ratio declared</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Vignette</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">radial-gradient, transparent to 45%</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Grain</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">mix-blend-luminosity — above the vignette</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Caption plate</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">own backdrop, always legible</span></li>
</ol>
</aside>
</main>
</section><section class="gtw-08 relative block w-full min-h-svh bg-gtw08-bg text-gtw08-ink" aria-labelledby="gtw-08-title">
<div class="absolute top-[clamp(1rem,3vw,1.75rem)] right-[clamp(0.85rem,3vw,1.75rem)] z-40">
<input type="checkbox" id="gtw-08-theme" class="gtw-08-theme peer sr-only">
<label for="gtw-08-theme" class="grid place-items-center w-11 h-11 rounded-full border border-gtw08-line bg-gtw08-surf text-gtw08-ink cursor-pointer transition-colors duration-200 hover:border-gtw08-acc peer-focus-visible:outline-2 peer-focus-visible:outline-gtw08-acc peer-focus-visible:outline-offset-[3px]">
<svg viewBox="0 0 24 24" class="w-5 h-5" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="sr-only">Toggle dark theme</span>
</label>
</div>
<main class="w-[min(78rem,calc(100%-clamp(1.5rem,5vw,4rem)))] mx-auto pt-[clamp(3rem,8vw,5.5rem)] pb-[clamp(2.5rem,6vw,4rem)] grid gap-[clamp(2rem,5vw,3.2rem)]">
<header class="max-w-[46rem] grid gap-[0.6rem]">
<p class="m-0 font-gtw08-mono text-[0.74rem] leading-none tracking-[0.16em] uppercase text-gtw08-acc">Roll 04 · 400 ISO · pushed one stop</p>
<h1 id="gtw-08-title" class="m-0 font-gtw08-display text-[clamp(2.6rem,8vw,5rem)] font-normal leading-[0.95] tracking-[-0.02em]">Contact sheet</h1>
<p class="m-0 max-w-[38rem] text-gtw08-mut text-[clamp(0.95rem,1.9vw,1.08rem)] text-pretty">Vignette below the grain, grain in <code class="font-gtw08-mono text-[0.9em]">luminosity</code>, weight set per frame with <code class="font-gtw08-mono text-[0.9em]">[--gtw08-weight:…]</code>. No filters, no canvas, no image processing — four layers of CSS over an ordinary <code class="font-gtw08-mono text-[0.9em]"><img></code>.</p>
</header>
<div class="grid gap-[clamp(1rem,2.5vw,1.6rem)] grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))] items-start">
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.13]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1015/1200/1500" alt="A river running between steep rock walls under an overcast sky" loading="lazy" width="1200" height="1500" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">01 · Gorge</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Bright, flat frame. Grain pushed to 0.13 to give the sky some tooth.</span>
</figcaption>
</figure>
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.07]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1039/1600/1000" alt="A waterfall seen from above, surrounded by dense forest" loading="lazy" width="1600" height="1000" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">02 · Falls</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Dark and contrasty. Grain held at 0.07 — any more and the shadows turn to mud.</span>
</figcaption>
</figure>
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.1]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1043/1200/1500" alt="A person standing on a rock outcrop looking across a valley" loading="lazy" width="1200" height="1500" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">03 · Outcrop</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Falloff kept outside the figure so the vignette reads as lens, not retouching.</span>
</figcaption>
</figure>
</div>
<aside class="grid gap-[0.9rem] p-[clamp(1.2rem,3vw,1.9rem)] rounded-[14px] border border-gtw08-line bg-gtw08-surf">
<h2 class="m-0 font-gtw08-display text-[clamp(1.3rem,3vw,1.7rem)] font-normal leading-[1.15]">Layer order, bottom to top</h2>
<ol class="gtw-08-stack list-none m-0 p-0 grid gap-[0.1rem]">
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Photograph</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">object-cover, aspect-ratio declared</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Vignette</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">radial-gradient, transparent to 45%</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Grain</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">mix-blend-luminosity — above the vignette</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Caption plate</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">own backdrop, always legible</span></li>
</ol>
</aside>
</main>
</section>@import "tailwindcss";
@theme {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
--font-gtw08-display: 'Instrument Serif', 'Playfair Display', Georgia, serif;
--font-gtw08-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
}
.gtw-08 {
width: 100%;
min-height: 100svh;
--gtw08-weight: 0.1;
--gtw08-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08n)'/%3E%3C/svg%3E");
--gtw08-noise-fine: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.25' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08f)'/%3E%3C/svg%3E");
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.55;
}
/* The grain image and its retina swap: one element, one resolution query, no utility worth writing. */
.gtw-08-grain {
background-image: var(--gtw08-noise);
}
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
.gtw-08-grain {
background-image: var(--gtw08-noise-fine);
opacity: calc(var(--gtw08-weight) * 1.15);
}
}
/* Counter numerals on the layer-order list — counters have no utility form. */
.gtw-08-stack {
counter-reset: gtw08;
}
.gtw-08-stack li {
counter-increment: gtw08;
}
.gtw-08-stack li::before {
content: counter(gtw08);
grid-row: span 2;
font: 400 0.8rem/1.6 var(--font-gtw08-mono);
color: var(--color-gtw08-acc);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
@media (prefers-color-scheme: light) {
.gtw-08 {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
}
}
@media (prefers-color-scheme: dark) {
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
}@import "tailwindcss";
@theme {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
--font-gtw08-display: 'Instrument Serif', 'Playfair Display', Georgia, serif;
--font-gtw08-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
}
.gtw-08 {
width: 100%;
min-height: 100svh;
--gtw08-weight: 0.1;
--gtw08-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08n)'/%3E%3C/svg%3E");
--gtw08-noise-fine: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.25' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08f)'/%3E%3C/svg%3E");
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.55;
}
/* The grain image and its retina swap: one element, one resolution query, no utility worth writing. */
.gtw-08-grain {
background-image: var(--gtw08-noise);
}
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
.gtw-08-grain {
background-image: var(--gtw08-noise-fine);
opacity: calc(var(--gtw08-weight) * 1.15);
}
}
/* Counter numerals on the layer-order list — counters have no utility form. */
.gtw-08-stack {
counter-reset: gtw08;
}
.gtw-08-stack li {
counter-increment: gtw08;
}
.gtw-08-stack li::before {
content: counter(gtw08);
grid-row: span 2;
font: 400 0.8rem/1.6 var(--font-gtw08-mono);
color: var(--color-gtw08-acc);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
@media (prefers-color-scheme: light) {
.gtw-08 {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
}
}
@media (prefers-color-scheme: dark) {
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
}Setup — do this once per project
Skip this if your project already runs Tailwind v4.
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>Compiles in the browser — perfect for a CodePen or a quick test, not for production.
npm install tailwindcss @tailwindcss/vite
npm install tailwindcss @tailwindcss/vite// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})// vite.config.js ("type": "module" in package.json)
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
})Any v4 setup works — Vite, PostCSS or the CLI. Nothing in this demo is bundler-specific.
@import "tailwindcss";
@theme {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
--font-gtw08-display: 'Instrument Serif', 'Playfair Display', Georgia, serif;
--font-gtw08-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
}
.gtw-08 {
width: 100%;
min-height: 100svh;
--gtw08-weight: 0.1;
--gtw08-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08n)'/%3E%3C/svg%3E");
--gtw08-noise-fine: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.25' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08f)'/%3E%3C/svg%3E");
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.55;
}
/* The grain image and its retina swap: one element, one resolution query, no utility worth writing. */
.gtw-08-grain {
background-image: var(--gtw08-noise);
}
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
.gtw-08-grain {
background-image: var(--gtw08-noise-fine);
opacity: calc(var(--gtw08-weight) * 1.15);
}
}
/* Counter numerals on the layer-order list — counters have no utility form. */
.gtw-08-stack {
counter-reset: gtw08;
}
.gtw-08-stack li {
counter-increment: gtw08;
}
.gtw-08-stack li::before {
content: counter(gtw08);
grid-row: span 2;
font: 400 0.8rem/1.6 var(--font-gtw08-mono);
color: var(--color-gtw08-acc);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
@media (prefers-color-scheme: light) {
.gtw-08 {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
}
}
@media (prefers-color-scheme: dark) {
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
}@import "tailwindcss";
@theme {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
--font-gtw08-display: 'Instrument Serif', 'Playfair Display', Georgia, serif;
--font-gtw08-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
}
.gtw-08 {
width: 100%;
min-height: 100svh;
--gtw08-weight: 0.1;
--gtw08-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08n)'/%3E%3C/svg%3E");
--gtw08-noise-fine: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.25' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08f)'/%3E%3C/svg%3E");
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.55;
}
/* The grain image and its retina swap: one element, one resolution query, no utility worth writing. */
.gtw-08-grain {
background-image: var(--gtw08-noise);
}
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
.gtw-08-grain {
background-image: var(--gtw08-noise-fine);
opacity: calc(var(--gtw08-weight) * 1.15);
}
}
/* Counter numerals on the layer-order list — counters have no utility form. */
.gtw-08-stack {
counter-reset: gtw08;
}
.gtw-08-stack li {
counter-increment: gtw08;
}
.gtw-08-stack li::before {
content: counter(gtw08);
grid-row: span 2;
font: 400 0.8rem/1.6 var(--font-gtw08-mono);
color: var(--color-gtw08-acc);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
@media (prefers-color-scheme: light) {
.gtw-08 {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
}
}
@media (prefers-color-scheme: dark) {
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
}<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500;600&display=swap">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500;600&display=swap">What changed in the port
Which parts became utilities, which stayed CSS, and why.
[--gtw08-weight:0.13]is the port's headline. Setting a custom property is itself a utility in v4, so the last inlinestyleattribute in the collection disappears and per-photograph grain weight becomes a reviewable class.group-hover:opacity-[calc(var(--gtw08-weight)*1.25)]shows why keeping weight in a property beats hard-coding two numbers: the hover state is derived from the resting one, per frame, with no duplication.- The retina swap and the list counters stay in CSS. Both are single-purpose rules on one element each, and
counter-incrementhas no utility (nor should it). - UI change from the CSS original: vignette stops moved from
rgba()tooklch()alpha so the falloff matches the token space, and the redundant--widegrid-span rules are gone — they resolved tospan 1at every width, which is whatauto-fitalready does.
Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Vignette Photo Overlay with Film Grain
Source: https://codefronts.com/design-styles/tailwind-css-grain-texture/tailwind-vignette-photo-overlay-film-grain/
A contact sheet of three frames, each with a lens vignette under a luminosity-blended grain layer and its own grain weight. No canvas, no image processing, no filters on the photograph — four ordinary layers over an <img>. The port's neat trick: the per-frame weight that needed an inline style attribute is now an arbitrary-property utility, [--gtw08-weight:0.13].
## HTML
```html
<section class="gtw-08 relative block w-full min-h-svh bg-gtw08-bg text-gtw08-ink" aria-labelledby="gtw-08-title">
<div class="absolute top-[clamp(1rem,3vw,1.75rem)] right-[clamp(0.85rem,3vw,1.75rem)] z-40">
<input type="checkbox" id="gtw-08-theme" class="gtw-08-theme peer sr-only">
<label for="gtw-08-theme" class="grid place-items-center w-11 h-11 rounded-full border border-gtw08-line bg-gtw08-surf text-gtw08-ink cursor-pointer transition-colors duration-200 hover:border-gtw08-acc peer-focus-visible:outline-2 peer-focus-visible:outline-gtw08-acc peer-focus-visible:outline-offset-[3px]">
<svg viewBox="0 0 24 24" class="w-5 h-5" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="sr-only">Toggle dark theme</span>
</label>
</div>
<main class="w-[min(78rem,calc(100%-clamp(1.5rem,5vw,4rem)))] mx-auto pt-[clamp(3rem,8vw,5.5rem)] pb-[clamp(2.5rem,6vw,4rem)] grid gap-[clamp(2rem,5vw,3.2rem)]">
<header class="max-w-[46rem] grid gap-[0.6rem]">
<p class="m-0 font-gtw08-mono text-[0.74rem] leading-none tracking-[0.16em] uppercase text-gtw08-acc">Roll 04 · 400 ISO · pushed one stop</p>
<h1 id="gtw-08-title" class="m-0 font-gtw08-display text-[clamp(2.6rem,8vw,5rem)] font-normal leading-[0.95] tracking-[-0.02em]">Contact sheet</h1>
<p class="m-0 max-w-[38rem] text-gtw08-mut text-[clamp(0.95rem,1.9vw,1.08rem)] text-pretty">Vignette below the grain, grain in <code class="font-gtw08-mono text-[0.9em]">luminosity</code>, weight set per frame with <code class="font-gtw08-mono text-[0.9em]">[--gtw08-weight:…]</code>. No filters, no canvas, no image processing — four layers of CSS over an ordinary <code class="font-gtw08-mono text-[0.9em]"><img></code>.</p>
</header>
<div class="grid gap-[clamp(1rem,2.5vw,1.6rem)] grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))] items-start">
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.13]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1015/1200/1500" alt="A river running between steep rock walls under an overcast sky" loading="lazy" width="1200" height="1500" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">01 · Gorge</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Bright, flat frame. Grain pushed to 0.13 to give the sky some tooth.</span>
</figcaption>
</figure>
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.07]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1039/1600/1000" alt="A waterfall seen from above, surrounded by dense forest" loading="lazy" width="1600" height="1000" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">02 · Falls</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Dark and contrasty. Grain held at 0.07 — any more and the shadows turn to mud.</span>
</figcaption>
</figure>
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.1]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1043/1200/1500" alt="A person standing on a rock outcrop looking across a valley" loading="lazy" width="1200" height="1500" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">03 · Outcrop</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Falloff kept outside the figure so the vignette reads as lens, not retouching.</span>
</figcaption>
</figure>
</div>
<aside class="grid gap-[0.9rem] p-[clamp(1.2rem,3vw,1.9rem)] rounded-[14px] border border-gtw08-line bg-gtw08-surf">
<h2 class="m-0 font-gtw08-display text-[clamp(1.3rem,3vw,1.7rem)] font-normal leading-[1.15]">Layer order, bottom to top</h2>
<ol class="gtw-08-stack list-none m-0 p-0 grid gap-[0.1rem]">
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Photograph</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">object-cover, aspect-ratio declared</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Vignette</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">radial-gradient, transparent to 45%</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Grain</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">mix-blend-luminosity — above the vignette</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Caption plate</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">own backdrop, always legible</span></li>
</ol>
</aside>
</main>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
--font-gtw08-display: 'Instrument Serif', 'Playfair Display', Georgia, serif;
--font-gtw08-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
}
.gtw-08 {
width: 100%;
min-height: 100svh;
--gtw08-weight: 0.1;
--gtw08-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08n)'/%3E%3C/svg%3E");
--gtw08-noise-fine: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.25' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08f)'/%3E%3C/svg%3E");
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.55;
}
/* The grain image and its retina swap: one element, one resolution query, no utility worth writing. */
.gtw-08-grain {
background-image: var(--gtw08-noise);
}
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
.gtw-08-grain {
background-image: var(--gtw08-noise-fine);
opacity: calc(var(--gtw08-weight) * 1.15);
}
}
/* Counter numerals on the layer-order list — counters have no utility form. */
.gtw-08-stack {
counter-reset: gtw08;
}
.gtw-08-stack li {
counter-increment: gtw08;
}
.gtw-08-stack li::before {
content: counter(gtw08);
grid-row: span 2;
font: 400 0.8rem/1.6 var(--font-gtw08-mono);
color: var(--color-gtw08-acc);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
@media (prefers-color-scheme: light) {
.gtw-08 {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
}
}
@media (prefers-color-scheme: dark) {
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
}
```Here's a working CSS demo from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Tailwind Vignette Photo Overlay with Film Grain
Source: https://codefronts.com/design-styles/tailwind-css-grain-texture/tailwind-vignette-photo-overlay-film-grain/
A contact sheet of three frames, each with a lens vignette under a luminosity-blended grain layer and its own grain weight. No canvas, no image processing, no filters on the photograph — four ordinary layers over an <img>. The port's neat trick: the per-frame weight that needed an inline style attribute is now an arbitrary-property utility, [--gtw08-weight:0.13].
## HTML
```html
<section class="gtw-08 relative block w-full min-h-svh bg-gtw08-bg text-gtw08-ink" aria-labelledby="gtw-08-title">
<div class="absolute top-[clamp(1rem,3vw,1.75rem)] right-[clamp(0.85rem,3vw,1.75rem)] z-40">
<input type="checkbox" id="gtw-08-theme" class="gtw-08-theme peer sr-only">
<label for="gtw-08-theme" class="grid place-items-center w-11 h-11 rounded-full border border-gtw08-line bg-gtw08-surf text-gtw08-ink cursor-pointer transition-colors duration-200 hover:border-gtw08-acc peer-focus-visible:outline-2 peer-focus-visible:outline-gtw08-acc peer-focus-visible:outline-offset-[3px]">
<svg viewBox="0 0 24 24" class="w-5 h-5" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
<span class="sr-only">Toggle dark theme</span>
</label>
</div>
<main class="w-[min(78rem,calc(100%-clamp(1.5rem,5vw,4rem)))] mx-auto pt-[clamp(3rem,8vw,5.5rem)] pb-[clamp(2.5rem,6vw,4rem)] grid gap-[clamp(2rem,5vw,3.2rem)]">
<header class="max-w-[46rem] grid gap-[0.6rem]">
<p class="m-0 font-gtw08-mono text-[0.74rem] leading-none tracking-[0.16em] uppercase text-gtw08-acc">Roll 04 · 400 ISO · pushed one stop</p>
<h1 id="gtw-08-title" class="m-0 font-gtw08-display text-[clamp(2.6rem,8vw,5rem)] font-normal leading-[0.95] tracking-[-0.02em]">Contact sheet</h1>
<p class="m-0 max-w-[38rem] text-gtw08-mut text-[clamp(0.95rem,1.9vw,1.08rem)] text-pretty">Vignette below the grain, grain in <code class="font-gtw08-mono text-[0.9em]">luminosity</code>, weight set per frame with <code class="font-gtw08-mono text-[0.9em]">[--gtw08-weight:…]</code>. No filters, no canvas, no image processing — four layers of CSS over an ordinary <code class="font-gtw08-mono text-[0.9em]"><img></code>.</p>
</header>
<div class="grid gap-[clamp(1rem,2.5vw,1.6rem)] grid-cols-[repeat(auto-fit,minmax(min(100%,17rem),1fr))] items-start">
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.13]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1015/1200/1500" alt="A river running between steep rock walls under an overcast sky" loading="lazy" width="1200" height="1500" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">01 · Gorge</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Bright, flat frame. Grain pushed to 0.13 to give the sky some tooth.</span>
</figcaption>
</figure>
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.07]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1039/1600/1000" alt="A waterfall seen from above, surrounded by dense forest" loading="lazy" width="1600" height="1000" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">02 · Falls</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Dark and contrasty. Grain held at 0.07 — any more and the shadows turn to mud.</span>
</figcaption>
</figure>
<figure class="group m-0 grid gap-[0.7rem] min-w-0 [--gtw08-weight:0.1]">
<div class="gtw-08-plate relative isolate overflow-hidden rounded aspect-[4/5] bg-[#101012]">
<img src="https://picsum.photos/id/1043/1200/1500" alt="A person standing on a rock outcrop looking across a valley" loading="lazy" width="1200" height="1500" class="absolute inset-0 z-0 block w-full h-full object-cover [filter:contrast(1.04)_saturate(0.94)] transition-[scale] duration-[620ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-[1.035] motion-reduce:transition-none motion-reduce:group-hover:scale-100">
<span aria-hidden="true" class="absolute inset-0 z-[1] pointer-events-none bg-[radial-gradient(120%_100%_at_50%_46%,transparent_45%,oklch(0.12_0.01_285/0.24)_76%,oklch(0.12_0.01_285/0.44)_100%)]"></span>
<span aria-hidden="true" class="gtw-08-grain absolute inset-0 z-[2] pointer-events-none opacity-[var(--gtw08-weight)] mix-blend-luminosity transition-opacity duration-300 group-hover:opacity-[calc(var(--gtw08-weight)*1.25)] motion-reduce:transition-none"></span>
</div>
<figcaption class="grid gap-[0.2rem]">
<b class="font-gtw08-mono text-[0.9rem] font-medium leading-tight tracking-[0.04em]">03 · Outcrop</b>
<span class="text-gtw08-mut text-[0.85rem] text-pretty">Falloff kept outside the figure so the vignette reads as lens, not retouching.</span>
</figcaption>
</figure>
</div>
<aside class="grid gap-[0.9rem] p-[clamp(1.2rem,3vw,1.9rem)] rounded-[14px] border border-gtw08-line bg-gtw08-surf">
<h2 class="m-0 font-gtw08-display text-[clamp(1.3rem,3vw,1.7rem)] font-normal leading-[1.15]">Layer order, bottom to top</h2>
<ol class="gtw-08-stack list-none m-0 p-0 grid gap-[0.1rem]">
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Photograph</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">object-cover, aspect-ratio declared</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Vignette</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">radial-gradient, transparent to 45%</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Grain</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">mix-blend-luminosity — above the vignette</span></li>
<li class="grid grid-cols-[1.6rem_minmax(0,1fr)] gap-x-[0.6rem] gap-y-[0.15rem] py-[0.55rem] border-t border-gtw08-line"><b class="font-semibold text-[0.92rem]">Caption plate</b><span class="font-gtw08-mono text-[0.8rem] leading-[1.4] text-gtw08-mut">own backdrop, always legible</span></li>
</ol>
</aside>
</main>
</section>
```
## CSS
```css
@import "tailwindcss";
@theme {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
--font-gtw08-display: 'Instrument Serif', 'Playfair Display', Georgia, serif;
--font-gtw08-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
}
.gtw-08 {
width: 100%;
min-height: 100svh;
--gtw08-weight: 0.1;
--gtw08-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08n)'/%3E%3C/svg%3E");
--gtw08-noise-fine: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='gtw08f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.25' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='170' height='170' filter='url(%23gtw08f)'/%3E%3C/svg%3E");
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.55;
}
/* The grain image and its retina swap: one element, one resolution query, no utility worth writing. */
.gtw-08-grain {
background-image: var(--gtw08-noise);
}
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
.gtw-08-grain {
background-image: var(--gtw08-noise-fine);
opacity: calc(var(--gtw08-weight) * 1.15);
}
}
/* Counter numerals on the layer-order list — counters have no utility form. */
.gtw-08-stack {
counter-reset: gtw08;
}
.gtw-08-stack li {
counter-increment: gtw08;
}
.gtw-08-stack li::before {
content: counter(gtw08);
grid-row: span 2;
font: 400 0.8rem/1.6 var(--font-gtw08-mono);
color: var(--color-gtw08-acc);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
@media (prefers-color-scheme: light) {
.gtw-08 {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #0a0a0b;
--color-gtw08-bg: oklch(0.16 0.003 285);
--color-gtw08-surf: #141416;
--color-gtw08-surf: oklch(0.21 0.004 285);
--color-gtw08-line: #262629;
--color-gtw08-line: oklch(0.3 0.005 285);
--color-gtw08-ink: #f0efec;
--color-gtw08-ink: oklch(0.95 0.005 85);
--color-gtw08-mut: #97948d;
--color-gtw08-mut: oklch(0.68 0.01 85);
--color-gtw08-acc: #e8b84b;
--color-gtw08-acc: oklch(0.8 0.13 85);
}
}
@media (prefers-color-scheme: dark) {
.gtw-08:has(.gtw-08-theme:checked) {
--color-gtw08-bg: #f2f0ec;
--color-gtw08-bg: oklch(0.95 0.008 85);
--color-gtw08-surf: #ffffff;
--color-gtw08-surf: oklch(1 0 0);
--color-gtw08-line: #ded9d1;
--color-gtw08-line: oklch(0.88 0.012 85);
--color-gtw08-ink: #0a0a0b;
--color-gtw08-ink: oklch(0.16 0.003 285);
--color-gtw08-mut: #635f58;
--color-gtw08-mut: oklch(0.48 0.012 85);
--color-gtw08-acc: #9a6b12;
--color-gtw08-acc: oklch(0.55 0.11 78);
}
}
```How this works
Order is the whole effect. Photograph, then vignette, then grain, then caption. Put the grain under the vignette and the corners come out smooth and plastic while the centre is textured — exactly backwards from how film behaves, because real grain is in the emulsion and the falloff is in the lens. Three z-index values enforce it.
luminosity is the correct blend for photography. overlay and soft-light both push colour around, which is visible on skin and sky. luminosity takes the grain's lightness and keeps the photograph's hue and chroma, so the frame gets tooth without a colour cast.
Grain weight is per photograph, not per site. A bright flat frame can take 0.13 and needs it; a dark contrasty frame turns to mud past 0.07. The CSS version carried that number in an inline style; here it is [--gtw08-weight:0.07] in the class list, which means it is visible in review and greppable in a codebase.
The retina swap. Above 2dppx the sheet switches to a finer noise tile and raises weight 15%, because the same grain spread over three times the pixels reads as dirt. That rule stays in CSS — it applies to one element and a resolution query has no utility worth writing.
Make it yours
- Per-frame weight:
[--gtw08-weight:…]. 0.06–0.08 for dark frames, 0.10–0.14 for bright flat ones. - Vignette depth: the two alpha stops in the radial gradient. Keep the inner stop at 45% or the falloff starts eating the subject.
- Aspect:
aspect-[4/5]is the classic contact-sheet crop.aspect-[3/2]for 35mm. - Swap
mix-blend-luminosityformix-blend-soft-lightif you want a warmer, more graded look and the images are not portraits. - Hover scale is
1.035. Above about 1.06 the grain visibly scales with the photograph and stops reading as emulsion.
Gotchas — read before shipping
- Grain below the vignette leaves smooth, plastic corners. The order photograph → vignette → grain is not stylistic, it is the whole effect.
mix-blend-luminosityon a layer insideoverflow-hiddenstill needsisolateon the plate, or the blend reaches the page background.- Always set
widthandheightattributes ANDobject-cover. Without them the sheet reflows as each photograph loads, which is a CLS failure on a real page. - Do not put the grain on the
<img>itself viafilter— it re-renders the decoded bitmap on every repaint and cannot be blended separately. - Scaling a grained photograph on hover scales the grain too if the noise sits inside the scaled element. Keep the grain layer a sibling of the image, as here.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 128+ | 111+ |
mix-blend-mode: luminosity, radial-gradient vignettes, aspect-ratio and object-fit are all Baseline. Safari applies blend modes in a slightly different colour space, so verify grain weight there before shipping. Photographs come from picsum.photos — swap the URL for your own CDN and nothing else changes.