Skip to main content
Back to Blog

Grit, Grain and a Box That Wants to Be Paper

Tom Hermans

Table Of Contents

Clean interfaces are good. They’re readable. They score well on Lighthouse. They never embarrass anyone in a client meeting.

They also feel like they were assembled by the same three Figma components everyone else is using.

I keep noticing this.

Every SaaS hero section. Every rounded card. Every CTA button — some shade of indigo, 8px radius, drop shadow on hover.

It’s fine. It’s incredibly, relentlessly fine.

What I’m Actually Going For

I grew up with magazines. Xeroxed zines. Comic books with slightly off-register colors you could feel were printed by someone who gave a damn and also slightly ran out of time.

Digital got clean and stayed there.

But lately I keep reaching for other things. The grain of an old photograph. The slight wobble of a rubber stamp. The way newsprint texture makes text feel embedded in the page rather than floating above it.

So this site has been quietly trying to drag some of that back.

Whirly Birdie — the display font — has wobble baked into its variable axes. BettyVeronica is my own hand-lettered comic font that refuses to align itself properly. SVG displacement filters make borders look drawn, not computed.

And now there’s a CTA component that looks like it was placed on the page by someone with a very good aesthetic sense and a slight tremor.

Cool Effect huh?

Get in touch

Five Layers Wearing One Coat

The naive version of a textured box: set a background image. Done.

The problem is that textures interact. But that’s also the advantage, that’s actually the point. A halftone pattern on top of a photo texture on top of a solid color needs to blend — not stack. You don’t want three visible layers. You want one surface that somehow has depth.

CSS blend modes are the answer. They’re also why this component took longer than it had any right to.

The final structure is five layers, all controlled through a single Astro component:

.ctaboxnew                ← container: rotate + skew live here
  ::before                ← halftone / pattern (floats above content)
  .ctaboxnew__bg          ← background shell — extends out by border-width
    ::before              ← photo / dirt texture (blends into fill)
    ::after               ← solid fill color (the foundation)
  .ctaboxnew__content     ← your slot content, z-index: 1

Read it bottom-up. Solid fill first. Texture blends over it. Content sits above both. Halftone hovers above everything. The container distorts the whole thing.

Each layer does one job. None of them know about the others.

The Blend Mode Kitchen

Here’s where it gets interesting.

The same dirt texture looks completely different depending on which blend mode you hand it.

overlay darkens shadows and brightens highlights — it adds contrast, makes the texture punch. screen makes dark pixels transparent — a black-and-white grain image becomes a delicate noise that barely registers. multiply makes everything darker, like the texture is printed on top. luminosity strips all color from the texture and only maps its brightness values onto the layer below.

In the component, two independent blend mode knobs:

/* .ctaboxnew__bg::before — the photo / dirt texture */
mix-blend-mode: var(--bg-b-blend, overlay);
opacity: var(--bg-b-opacity, 0.75);

/* .ctaboxnew::before — the halftone / pattern layer */
mix-blend-mode: var(--box-b-blend, normal);
opacity: var(--box-b-opacity, 0.15);

That’s four numbers describing the entire character of the surface.

Swap bgBlend from overlay to luminosity and the same dirt texture becomes a ghost. Drop bgOpacity from 0.75 to 0.2 and it’s barely a suggestion. Push beforeBlend to hard-light and the halftone punches through like a badly registered screenprint. Nudge beforeOpacity up to 0.6 and suddenly it looks like someone spilled a Risograph on it.

You’re not configuring a component. You’re mixing.

The Border That Can’t Decide What Shape It Is

The SVG squiggle filters live in Layout.astro. Six of them — squiggle-0 through squiggle-6 — each one a feTurbulence + feDisplacementMap pair with different frequencies and displacement scales.

Low scale: the border looks like it was drawn with a fairly steady hand. High scale: it looks like it was drawn by someone who had opinions about the deadline.

<!-- squiggle-0: barely there, just a bit tired -->
<feTurbulence baseFrequency="0.02" numOctaves="2" />
<feDisplacementMap scale="1.8" />

<!-- squiggle-6: scale 24, this is not a rectangle anymore -->
<feTurbulence baseFrequency="0.022" numOctaves="3" />
<feDisplacementMap scale="24" />

These are exposed as CSS custom properties:

--filter-squiggle-0: url(#squiggle-0);
--filter-squiggle-1: url(#squiggle-1);
/* …through squiggle-6 */

Applied to .ctaboxnew__bg — just the background shell, not the content. The overflow: clip on that layer keeps the textures from bleeding out. The content stays sharp. Only the container misbehaves.

The Geometry

Rotate and skew sit on the outer container. Not on the background shell.

.ctaboxnew {
  rotate: calc(var(--box-rot, 0) * 1deg);
  transform: skew(calc(var(--box-skew, 0) * 1deg));
}

The calc(n * 1deg) is a CSS trick for unitless number variables — you can’t do unit math in CSS without converting first, and storing raw degree values makes prop composition much cleaner. In Astro you write skew={-2}, not skew="-2deg".

The background shell stays straight. The squiggle filter distorts it. The outer box rotates the whole composition.

Three transforms happening at three different levels. None of them fighting each other.

The Full Thing

Here’s an instance with everything turned on:

<CTABoxNew
  class="my-20"
  bgColor="var(--yellow-5)"
  borderColor="var(--yellow-4)"
  borderWidth={12}
  radius={16}
  skew={-1}
  rotate={-2.5}
  filter="var(--filter-squiggle-4)"
  textureBg="var(--bg-tex7)"
  bgBlend="overlay"
  bgOpacity={0.45}
  textureBefore="var(--bg-tex19)"
  beforeBlend="overlay"
  beforeOpacity={0.7}
>
  <Heading level={2}>Cool Effect huh?</Heading>
  <a href="/contact">
    <Text fontFamily="comic">Get in touch</Text>
  </a>
</CTABoxNew>

And here it is, actually rendered, right here in the article:

Cool Effect huh?

Get in touch

Strip it back to just bgColor and it’s a clean colored box:

Still a box. Just a quieter one.

Same component. Zero textures. All the props are optional.

Add all four and it doesn’t look like it came from a design system at all.

Which is, I think, the whole point.

Clean Is an Aesthetic, Not a Virtue

There’s an assumption baked into modern web design that “polished” means “frictionless.” That refinement means removing texture. That visible personality is somehow unprofessional. I just think it points to creativity.

For me the most interesting visual work usually has some grit to it. Some tension. An imperfection that signals a human hand was involved somewhere in the process. Even if that hand was mostly typing CSS variables and abusing calc().

The goal with this site — and with this component — is not slickness.

It’s presence.

Something that feels like a surface you could touch. A screen that’s slightly, stubbornly trying to be paper. A box that, honestly, doesn’t want to be a box at all.

Back to Blog

Let's Talk

Tom avatar

Get in touch

I work at the intersection of design and code.
Interested? Hit me up.
tomhermans@gmail.com

Copyright © 2026 Tom Hermans. Made by Tom Hermans .

All rights reserved 2026 inc Tom Hermans