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 polarity
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.


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.