Placeholders
The placeholder argument to img() fills the space with something
cheap while the full image loads. A placeholder is a technique that returns an
attribute delta — most often a CSS background in the style attribute, but any
attribute (an src swap, an onload hook, a data-* field) is fair game. That
admits every technique without foreknowledge — a blurred hash, a dominant color, a
gradient, a tiny remote image, or a JS-driven lazy-load.
$el = $image->img(alt: 'Sunset', placeholder: 'blur');
echo $el->attributes()['style'];
// background:url('data:image/webp;base64,…') center / cover no-repeat
The accepted types are PlaceholderInterface | Stringable | string | null. A string
is either an intent token resolved against the catalog, or a raw CSS background
value.
Built-in tokens
Every image carries a small catalog of intent tokens, so this works with no
configuration:
| token | technique |
|---|---|
blur |
thumbhash |
thumbhash |
thumbhash |
blurhash |
blurhash |
none |
no placeholder |
The hash techniques decode a tiny variant of the image, so they run on local
providers only; on a URL-grammar provider (or any backend that cannot inline) they
degrade silently to no placeholder.
$image->img(alt: 'Sunset', placeholder: 'blur'); // thumbhash
$image->img(alt: 'Sunset', placeholder: 'thumbhash');
$image->img(alt: 'Sunset', placeholder: 'blurhash');
$image->img(alt: 'Sunset', placeholder: 'none'); // no placeholder
The hash is derived from the image and wrapped as
url('<data-uri>') center / cover no-repeat. It rides the same variant cache as
any other encode.
Overriding the catalog
The catalog is an array<string, PlaceholderInterface>. The factory is the override
point — replace or extend the default set, and every image it produces picks it up:
$factory = new ImageFactory($provider, placeholders: [
'blur' => new App\Placeholder\DominantColor(),
'brand' => new App\Placeholder\BrandColor(),
]);
$factory->image('hero.jpg')->img(alt: 'Hero', placeholder: 'brand');
An unknown token (a typo) falls through as a raw CSS background value, silently.
Precomputed values (string / Stringable)
Pass any value you computed elsewhere — a stored hash, a CMS field, an upload-time
LQIP. A catalog token is resolved first; every other string is treated as a raw CSS
background value:
$image->img(alt: 'Logo', placeholder: '#7c9a8e'); // dominant color
$image->img(alt: 'Hero', placeholder: 'linear-gradient(#eee, #ccc)');
$image->img(alt: 'Hero', placeholder: $row->lqip); // stored data-URI
A URI-shaped value (data:, https:, //, /) is auto-wrapped in url('…')
with CSS-string escaping, so a stored data-URI or an Image (which is Stringable
→ its URL) Just Works and cannot break out of the wrapper:
$image->img(alt: 'Hero', placeholder: $factory->image('hero.jpg')->width(24));
// a 24px-wide URL, wrapped as url('…')
Trust boundary
A non-URI value is validated as a CSS background value: ;, {, and } are
never legal in the shorthand and throw InvalidArgumentException. This closes
CSS-declaration breakout from CMS- or DB-sourced values (a raw
red;position:fixed;inset:0 would otherwise become a full-page overlay). Validation
is eager, at img() call time; the expensive resolution stays lazy inside
attributes().
Custom techniques (PlaceholderInterface)
For a computed technique — a dominant-color extractor, a stored-hash reader, a
lazy-load rig — implement the interface. resolve() receives the Image and the
element’s already-computed attributes, and returns a delta to merge over them,
or null to degrade:
namespace App\Placeholder;
use Timber\Chainsaw\Image;
use Timber\Chainsaw\Output\PlaceholderInterface;
final class DominantColor implements PlaceholderInterface
{
/**
* @param array<string, string> $computed
*
* @return array<string, string|null>|null
*/
public function resolve(Image $image, array $computed): ?array
{
$hex = $this->lookup($image->source); // your own store
return $hex === null ? null : ['style' => 'background:#' . $hex];
}
}
Register it under a token in the catalog, or pass an instance straight through:
$image->img(alt: 'Hero', placeholder: new App\Placeholder\DominantColor());
The delta merge
The returned delta merges over the element’s computed attributes: style
concatenates (the placeholder value leads), every other key overrides, and a null
value drops the key. So a technique is not limited to a background — it can swap
src to a tiny inline image, stash the real URL on data-src, and drop srcset
for a JS-driven reveal:
public function resolve(Image $image, array $computed): ?array
{
return [
'src' => $this->tinyDataUri($image),
'data-src' => $computed['src'],
'srcset' => null, // dropped
'onload' => 'this.src = this.dataset.src',
];
}
The receiver
The Image handed to the techniques and to resolve() is the element’s base
image. For a Srcset-backed element it is the smallest variant — a 32px hash
from a 400w variant is visually identical to one from a 3200w variant, at a
fraction of the decode cost. (fallback() is the widest variant and is never
used here.)
Transparent sources
A CSS-background placeholder stays permanently visible behind transparent output
(PNG / WebP logos) — nothing clears it on load without JS. For transparent sources,
prefer none, or a solid color that matches the design.
Self-contained inline style
By default the library emits no layout CSS — width/height attributes are enough
for browsers to reserve the box, and inline styles fight strict CSP. When markup
must survive outside the site stylesheet (newsletters, RSS, syndicated fragments),
opt into the self-contained recipe with inlineStyle: true:
$image->img(alt: 'Hero', layout: 'constrained', inlineStyle: true)->style();
// max-width:100%;height:auto;aspect-ratio:800/500
| Layout | emitted style |
|---|---|
constrained |
max-width:100%;height:auto;aspect-ratio:W/H |
full-width |
width:100%;height:auto;aspect-ratio:W/H |
fixed / no layout |
nothing (attributes suffice) |
The height:auto is included on purpose — without it the height attribute’s
presentational hint wins and aspect-ratio never engages. When dimensions are
unknown the string is emitted minus aspect-ratio.
CSP note
Both inlineStyle and a background-style placeholder write into the style
attribute. A strict style-src without unsafe-inline will drop them. Either allow
inline styles for image elements, or leave inlineStyle off and rely on width/height
attributes plus the site stylesheet’s height: auto reset.
A background placeholder leads the computed style; the inline-style recipe
follows; any user-supplied style attribute is appended last. The whole delta is
resolved and merged in attributes(), so it lands on the rendered element, not in
the bare style() recipe.