Image banners, focal points, scrims, and textures

An Espalier banner is one responsive image covered into a ratio-defined frame. The focal point preserves the meaningful region as that frame changes. A scrim provides text legibility; an optional texture provides style. These are separate layers so decoration can never be mistaken for the contrast mechanism.

This guide is also the committed visual contract for the banner presets. The component implementation, examples, and browser review use the exact values below.

Layer order

From back to front, banner mode renders the media, scrim, texture, and real overlay content. The scrim and texture ignore pointer input and accessibility APIs. Overlay content stays semantic and interactive.

banner-scheme selects the banner's polarity and defaults to the active scheme. A dark banner darkens the image behind light ink; a light banner lifts the image toward paper behind dark ink. The default scrim, texture, and overlay inks derive from the theme's semantic tokens (--esp-color-background for surfaces, --esp-color-headings for overlay text) with the lightness pinned per polarity, so a surrounding context re-tints them and an explicit banner-scheme still reads on any page scheme:

Polarity Scrim/texture ink Overlay ink
dark Background hue pinned to oklch lightness 0.13/0.12 Headings hue pinned to lightness 0.97
light Background hue pinned to lightness 0.97/0.98 Headings hue pinned to lightness 0.2

When the banner polarity matches the page scheme the theme's colors already have the right polarity, so the defaults use them verbatim: the scrim is exactly --esp-color-background and the overlay ink exactly --esp-color-headings. The pinned derivations in the table apply only to the cross-scheme override case.

Overlay text also carries a soft polarity-matched text-shadow halo by default — dark ink gets a light halo, light ink a dark one — overridable (or removable with none) through --esp-image-overlay-text-shadow.

On light banners the darkening texture blends flip to their lightening counterparts: halftone and grunge render with screen instead of multiply.

Scrim recipes

--esp-image-scrim-color defaults to the polarity ink above (with a oklch(0.12 0.015 255) fallback outside a theme root). Each recipe uses the selected strength as the opacity of the whole scrim layer:

Strength Opacity
soft 0.36
medium 0.56
strong 0.76

The public --esp-image-scrim-opacity token overrides those values. Gradient stops are fixed by anchor:

Scrim Exact recipe
flat Solid scrim color across the frame.
top to bottom: color 0% 20%, 68% color mix at 48%, transparent at 78%.
bottom to top: color 0% 20%, 68% color mix at 48%, transparent at 78%.
left to right: color 0% 20%, 68% color mix at 48%, transparent at 78%.
right to left: color 0% 20%, 68% color mix at 48%, transparent at 78%.
radial Circle at center: color 0%, 72% mix at 34%, 28% mix at 64%, transparent at 82%.

With scrim="auto", an empty overlay resolves to none; top and bottom content positions resolve to the matching anchor; centered content resolves to radial. An explicit scrim always wins.

The strong preset is intended to clear APCA Lc 75 for body text and Lc 60 for headings over a typical mid-key image. No fixed overlay can guarantee contrast over arbitrary photography, so the consumer must review its actual image and may override color or opacity.

Procedural texture recipes

CSS-gradient textures use a shared pitch selected by texture-scale. The public --esp-image-texture-scale length overrides the selected pitch per instance.

Scale Pitch
fine 4px
medium 8px
coarse 14px

Dot and line geometry scales with the pitch so every scale reads as a screen rather than vanishing specks:

Texture Exact recipe Default opacity Blend mode
dots Tiled radial dot: ink through pitch × 0.17, transparent 0.35px later. 0.8 normal
halftone Larger dot tile (ink through pitch × 0.22), always masked — from opaque at the active scrim anchor (bottom, by default) to transparent at 82%. 0.9 multiply (screen on light banners)
scanlines Repeating line along the block axis: ink through pitch × 0.3, transparent through the selected pitch. 0.35 normal
duotone 135deg gradient from the shadow token at 0% 42% to the highlight token at 100%. 0.9 color

--esp-image-texture-color defaults to the polarity ink. Duotone defaults derive from the theme: the shadow stop is the primary hue pinned to oklch lightness 0.32, the highlight stop is the complementary hue pinned to 0.84.

SVG textures use discrete shared filters; CSS lengths cannot change an SVG filter's frequency. Because CSS filter: url(#id) only resolves inside the referencing element's own tree scope, each banner's shadow root receives one copy of these definitions (installed lazily when an SVG texture is active):

The three SVG presets are deliberately pushed to opposite ends so they read distinctly: paper is a low-frequency, many-octave mottle with a contrast stretch (a soft cloudy surface); grain is a very-high-frequency, one-octave speckle (fine film grain); grunge thresholds the noise into sparse distressed marks.

Texture Scale feTurbulence baseFrequency numOctaves Post-processing Opacity Blend
paper fine fractalNoise 0.016 6 saturation 0 + contrast 2.6 0.9 overlay
paper medium fractalNoise 0.011 6 saturation 0 + contrast 2.6 0.9 overlay
paper coarse fractalNoise 0.007 6 saturation 0 + contrast 2.6 0.9 overlay
grain fine fractalNoise 0.65 1 saturation 0 + contrast 1.7 0.72 overlay
grain medium fractalNoise 0.5 1 saturation 0 + contrast 1.7 0.72 overlay
grain coarse fractalNoise 0.38 1 saturation 0 + contrast 1.7 0.72 overlay
grunge fine fractalNoise 0.14 2 threshold matrix below 0.42 multiply (screen on light banners)
grunge medium fractalNoise 0.09 3 threshold matrix below 0.42 multiply (screen on light banners)
grunge coarse fractalNoise 0.05 4 threshold matrix below 0.42 multiply (screen on light banners)

Both grain and paper stretch contrast with a per-channel linear transfer (slope from the table, intercept = (1 − slope) / 2 so mid-gray stays fixed while deviations widen). Without it the desaturated noise clusters near mid-gray and washes out under the blend — grain barely reads and paper looks like faint grain instead of a distressed surface. Grain also stays at a visible ~2px grain size: sub-pixel frequencies average out to nothing.

The SVG texture layer paints an opaque white source; the referenced filter replaces that paint with generated noise, so the layer never renders as a solid fill.

Blend modes only composite with the photo when no ancestor between the texture layer and the image creates a stacking context; the banner frame isolates the whole stack instead, so blends reach the photo but never the page behind the component.

The grunge threshold matrix derives its marks from the noise alpha channel (alpha' = 0.95 − 1.4·A, keeping only the sparse low-alpha tail — an RGB-sum threshold passes almost nothing over real noise output), with the mark color a constant offset L per polarity variant. Because the filter paints the marks itself, grunge ships two variant sets: grunge-* with black marks (L = 0, for multiply on dark banners) and grunge-light-* with white marks (L = 1, for screen on light banners — screen with black marks would be a no-op):

0 0 0 0 L
0 0 0 0 L
0 0 0 0 L
0 0 0 -1.4 0.95

It is followed by a discrete alpha transfer with table values 0 0 0 1 1. All filters use seed="17", stitchTiles="stitch", and x/y/width/height of -10%/-10%/120%/120%.

Reference sheets

The sheets below are captured by node scripts/capture-banner-reference.js from scripts/fixtures/esp-image-banner-reference.browser.html, which renders real esp-image banners against the current build; the same run pixel-asserts the SVG-filter textures over flat media before writing the sheets. Each row shows the fine, medium, and coarse variation over the repository's existing, provenance-cleared bright and dark canvas images.

Bright-photo banner preset reference

Dark-photo banner preset reference

Focal point, ratios, and server rendering

focus is a normalized point. focus="0 0" is the logical top-left of the image, focus="1 1" is the bottom-right, and the default is 0.5 0.5. The public --esp-image-object-position token has final precedence.

In JavaScript the normalized object is .focusPoint, not .focus: preserving the platform's callable HTMLElement.focus() method keeps the component focusable through ordinary DOM APIs. The HTML and storage contract remains the compact focus="x y" serialization.

ratio accepts a positive number or <number> / <number>. compact-ratio replaces it when the component's own width is below --esp-image-compact-width (default 40rem). An explicit host block size wins over either ratio.

Before the component upgrades, a server renderer that needs focal positioning must set the equivalent CSS itself, for example:

<esp-image banner focus="0.7 0.35" style="--esp-image-object-position: 70% 35%;">
  <img src="/banner.jpg" alt="A person standing beside a greenhouse" />
</esp-image>

Use <esp-focus-picker> to author that value visually. Its esp-focus-picker-changed event detail is the same {x, y} object assigned to esp-image.focusPoint. Per-breakpoint art direction remains a <picture> concern; use alternate <source media> images when one focal point cannot describe the intended composition.

<esp-focus-picker
  src="/assets/focus-picker-unsplash.jpg"
  alt="A woman standing in a sunlit field with her back to the camera"
  focus="0.65 0.55"
  ratio="3/1"
  compact-ratio="3/2"
></esp-focus-picker>

The demo photograph is by Sasha Freemind on Unsplash.

Shipped image textures

The optional package stylesheet maps three generated, first-party 256×256 tiles onto the same consumer-image tokens. The tiles are inlined as data URIs, so importing the stylesheet is the only step — the classes work without serving or configuring any asset paths:

<link rel="stylesheet" href="/css/textures/banner-textures.css">
<esp-image banner class="esp-texture-paper" ratio="3/1">
  <img src="/assets/canvas-bg.jpg" alt="A colorful floral pattern">
</esp-image>

Use .esp-texture-paper, .esp-texture-grain, or .esp-texture-grunge. Applications can instead set --esp-image-texture-image and its repeat, position, size, opacity, and blend-mode tokens directly. Both paths render through the shared texture image layer. The committed assets regenerate with node scripts/generate-banner-textures.js and verify with --check. Published-package consumers import the same stylesheet through the stable @taprootio/espalier/textures/banner-textures.css subpath.

Registered textures

Hand-wiring the consumer tokens on every banner is verbose and not reusable. The application's texture vocabulary is owned by esp-root: EspalierRoot.registerTexture(name, definition) turns an image-backed look into a named preset any banner under the root selects with texture="<name>":

import { EspalierRoot } from "@taprootio/espalier";

EspalierRoot.registerTexture("pressed-paper", {
  image: "repeating-linear-gradient(-45deg, oklch(1 0 0 / 0.3) 0 3px, transparent 3px 9px)",
  blendMode: "soft-light",
  opacity: 0.9,
});

image accepts any CSS background-image value — a gradient as above, a data URI, or a url() the application serves itself. Espalier's own defaults never depend on a served asset.

The plain registerImageTexture / registeredImageTextures module functions remain available from the package root, which also registers the full component catalog, and from a dedicated side-effect-free subpath for code that must not register components:

import {
  BUILT_IN_IMAGE_TEXTURES,
  isBuiltInImageTexture,
  registerImageTexture,
  registeredImageTextures,
} from "@taprootio/espalier/shared/image-texture-registry";

Importing that subpath defines no custom element and touches no DOM, so a generator, a server-rendered shell, or a build script can enumerate and validate the texture vocabulary in Node without a DOM shim. It is the same registry the components read — the root entry re-exports BUILT_IN_IMAGE_TEXTURES, registerImageTexture, and registeredImageTextures from this same module — so nothing can drift between the two.

Definitions carry image (required) plus optional size, repeat, position, opacity, and blendMode, mapping one-to-one onto the consumer tokens — and explicit --esp-image-texture-* tokens still override a registered preset per banner. Names are lowercase kebab-case and may not shadow a built-in preset; re-registering a name replaces it. Live banners re-render on registration, so a banner may reference a name before its registration script runs. registeredImageTextures() returns a snapshot of the registry for building editor preset pickers, and script-only contexts (such as doc demos) can use the registerTexture / registeredTextures statics on the esp-root class instead of module imports.

Resilience

In forced colors, decorative layers are removed and overlay content receives a solid Canvas background with CanvasText. Reduced transparency strengthens the scrim and removes texture. Print removes texture and gives overlay content a solid paper-safe background. All presets are static, so reduced motion requires no alternate animation behavior.

Components API Guides Getting started Styling Espalier Browser support GitHub npm package Taproot I/O