Ultralight Carousel via SSE releases
A light WordPress carousel: one image in the page, the rest stream in over SSE. Fast first paint, still works without JavaScript. Built on Datastar, coded by Claude under human direction.
by Loïc Truchot · github.com/ltruchot/ultralight-carousel-via-sse · website
Install
The author publishes release zips, so WP-CLI can install straight from GitHub:
wp plugin install https://github.com/ltruchot/ultralight-carousel-via-sse/releases/download/v0.5.1/hypermedia-carousel-for-datastar-0.5.1.zipA WordPress block that streams its slides over Server-Sent Events. The page ships one image, so the first paint, the first load and the largest contentful paint stay fast and uncluttered; the rest arrive in a single burst and the rotation runs in the browser. Without JavaScript, or for a visitor who asked for reduced motion, the page renders perfectly normally with that one image and no slideshow. Built on Datastar, bundled, never from a CDN.
Free and open source: GPL v2 or later, no paid version, no account, no tracking.
User-facing documentation lives in readme.txt, which is what wordpress.org
publishes. This file is for people reading the source.
Few options for now, on purpose. If you need one that is not there, open an issue and say what you are trying to do.
Naming
The plugin was developed under the working name Hypermedia Carousel for Datastar and renamed
before its first release. The code prefix ulcar (ULtralight CARousel) is on everything a site
can touch -- the block name ulcar/carousel, the CSS classes, the option, the REST namespace,
the filters -- and it will not change again: those names are written into pages and themes.
Running the tests
cd e2e
BASE_URL=http://localhost:8210 npm test # Chromium and Firefox
BASE_URL=http://localhost:8210 npm run test:all # the same, plus WebKit
test:all runs inside the official Playwright image. WebKit installs like any other browser but
needs three system libraries the other two do not (libicu74, libxml2, libflite1), and putting
those on a machine takes root. The image carries them, so nothing has to be installed and nothing
on the machine changes. Keep its tag in e2e/package.json in step with the Playwright version.
Measured on all three: 87 end-to-end tests. The unit suite and the coding-standards check run without a site:
composer install
composer exec -- phpunit # unit tests
composer exec -- phpcs # WordPress coding standards
Written by an agent
This plugin was coded end to end by Claude, agentically -- design, implementation, tests, hardening and documentation by Claude Opus 5; the pre-release audit, the rename and the corrections it brought by Claude Fable 5.1 -- under the direction of @ltruchot, who set the constraints, arbitrated the trade-offs and rejected the first answer more than once.
That claim is only worth something if you can check it, so here is what it actually meant in practice:
- Nothing was asserted that had not been measured. The burst is 3 585 bytes and closes in 44 ms because that was measured on a running site, not estimated. The security boundary is described by the responses the endpoint actually gave to forged requests.
- Every test was qualified by breaking what it tests. Eleven deliberate
sabotages of the code, ten caught on the first pass -- and the eleventh
revealed a genuine gap, now covered. Four sabotages of
.distignore, all caught, but only after the first version of that check came back green twice and had to be rewritten: it modelled different rules from the onesrsyncapplies. - The mistakes are in the git history rather than tidied out of it. A lint
script that reported success while thirteen fatal errors scrolled past. A
documented limitation about Content-Security-Policy that the bundled version
had already fixed. A settings sanitiser that turned a submitted
-10into ten seconds. A README that still described controls and a View Transition three versions after both were removed. Each was found, fixed, and written down where the next person will read it.
If you want the reasoning behind a decision rather than the result, the commit messages carry it: they say what changed, what was measured, and what was rejected.
It brings no styles of its own
The block streams images into a container with an id and fades one into the next. That is its whole job.
It ships no sizing, no positioning, no colour and no icons. Only the theme knows how big the box should be, and how an image that does not match its shape should be cropped or padded or blurred at the edges. So those decisions are left where the knowledge is. A plugin that guessed would force every theme to out-specify the guess.
blocks/carousel/style.css does exactly two things: it stacks the slides so two
can be on screen at once, and it cross-fades the one arriving over the one
leaving. editor.css is the one exception, and a narrow one: it lays the slides
out flat in the editor so an author can see what they picked. None of it reaches
a visitor.
Why not a View Transition
The slides arrive from the server, so document.startViewTransition is the
obvious answer. It was the first implementation, and it was wrong.
startViewTransition captures the document element: the root carries
view-transition-name: root by default, so every swap cross-fades the entire
viewport over itself. Measured on a real page, one swap at 1280×900: 597 604
pixels changed outside the carousel, over the full width and the full height.
Decorative shapes elsewhere on the page flickered on a five second beat.
Neutralising that means ::view-transition-old(root) { animation: none }, and
that rule is document-wide. A plugin has no business breaking the
cross-document transitions a theme may run. The snapshot is also lifted into the
top layer, escaping any mask or clip an ancestor applies to the image.
A cross-fade of two stacked images cannot reach a pixel outside the track, cannot escape a mask, and needs no global rule. Measured again after the change, on the same page: nothing outside the carousel beyond what changing the photograph already touched.
The styling contract
Because the block ships no styles, its class names are its public surface, and they are treated as one: they will not change without a major version and a changelog entry. A theme that styles them has no other way to reach the markup, so leaving them undocumented would make every such theme depend on an accident.
| Name | What it is |
|---|---|
ulcar-carousel |
The container: id, ARIA region, signals. |
ulcar-track |
Wraps the slides and stacks them. |
ulcar-slide |
One slide. The ones off screen carry hidden, and the stylesheet renders them display: block; visibility: hidden: out of the accessibility tree and the tab order, but still able to fade. |
--ulcar-fade |
Custom property: the length of the cross-fade. The plugin writes the configured value here, or 0ms when the setting says no transition; the stylesheet falls back to 1000ms if it is unset. |
Only one layer moves, and that is not a detail
Fading both slides at once is the obvious way to write a cross-fade, and it is wrong. Two half-transparent layers do not add up to an opaque one: measured mid-swap, 0.49 over 0.51 covered 0.75 of the box, so a quarter of the container showed through. On a light background that reads as a flash of light rather than a dissolve, and lengthening the fade makes it worse, because the flash lasts longer.
The slide that is leaving carries hidden, so the stylesheet puts it on top and
fades it out over an incoming slide that is already fully opaque. Coverage never
leaves 1. A theme that restyles z-index inside the track has to preserve that.
It also means nothing fades in, which removes an entry animation the plugin used to have to arm after the burst, and with it the risk of fading in the first slide, almost always the LCP element, on every visit.
Why visibility and not a discrete display transition
Holding the outgoing slide on screen with transition: display … allow-discrete
is the modern answer and it is not portable. Measured on the live site:
Chromium 151 held the slide for the length of the fade; Firefox 153 set
display: none on the first frame, while reporting
transition-behavior: allow-discrete and answering true to
CSS.supports( 'transition-behavior', 'allow-discrete' ).
visibility needs no discrete-transition support, is animatable everywhere, and
stays visible until the transition ends. It removes the slide from the
accessibility tree and from the tab order exactly as display: none did, which
was the whole reason for using the hidden attribute rather than opacity: 0.
The display: block that goes with it carries !important, because the HTML
rendering spec writes [hidden] { display: none !important } and a normal
author declaration loses to it.
Each slide paints as one piece
isolation: isolate on every slide is not tidiness, and it is part of the
contract: a theme may style anything inside a slide, and cannot lift it out.
Without it a slide is not a stacking context, so a descendant carrying a
z-index (a theme writing img { position: relative; z-index: 1 } is enough,
and a real one does) is composited against a far ancestor instead. The incoming
image then paints above the outgoing slide whatever z-index the plugin gives it,
and the fade becomes a hard cut.
Measured mid-fade, outgoing slide at 0.78 opacity: 4 % of the pixels differed from the settled result without the isolation, 95 % with it.
How it works
- The block's server render emits the shell, slide 1, and an empty placeholder for the element that will drive the rotation. Nothing else is in the page. **There is no `
Releases
2 releases. Each count is every asset in that release; expand a row for the breakdown.