Hello ![]()
The basic idea
The goal was to build a skeleton loader that is generated from the actual Discourse UI, rather than relying on a hardcoded skeleton template.
The builder lets an administrator select real elements on the page and turn them into skeleton regions.
For example:
.title
.avatar
.topic-excerpt
.btn
.category-breadcrumb
The component then uses those selectors to generate the skeleton at runtime.
Preview Skeleton
The interesting part is that the administrator doesn’t have to write the selectors manually. The builder analyzes the selected element and generates several candidate selectors.
Generating useful selectors turned out to be harder than expected
One of the first problems I ran into was selector generation.
A naïve implementation can easily produce something like:
.container.list-container.--topic-list .row.full-width .contents ...
Technically valid, but far too specific for a reusable skeleton configuration.
It can become even worse with icons, where the generated selector may include implementation details such as SVG-related classes.
What I really wanted was something closer to:
.badge-category__name
or:
.badge-category__wrapper .d-icon
instead of a selector describing the entire DOM path.
So the builder now generates multiple candidates and scores them based on things like:
- selector depth
- number of classes
- repeated matches
- state-related classes
- technical SVG/icon classes
- whether the selector still matches the selected element
The result is a list of recommended selectors that the admin can choose from or edit manually.
Hidden elements
There is also a separate picker for elements that should simply disappear while the skeleton is displayed.
For example:
.alert.alert-info
This turned out to be useful for things such as announcement banners or temporary notices that exist while building/testing but should not affect the skeleton layout.
One interesting problem here was that hiding an element must not leave an empty space behind.
So the excluded elements are not just treated as a simple display: none list - the geometry calculation also has to understand that the element is not part of the final layout.
Preview Skeleton
Navigation
Probably the biggest challenge was navigation.
The desired behavior was:
click
↓
show skeleton immediately
↓
Discourse changes route
↓
destination DOM appears
↓
hide skeleton
The tempting solution was to hook deeply into the navigation lifecycle and wait for the DOM to completely settle.
That turned out to be the wrong approach.
At one point the skeleton could remain visible for several seconds after the actual content was already there.
The lesson was simple:
The skeleton should not become a DOM-readiness gate.
Once the destination has enough real content to take over, the skeleton should get out of the way.
That made a huge difference to the perceived speed of the navigation.
Viewports
Discourse already has a responsive viewport system, so the component now uses the same breakpoint abstraction:
xs
sm
md
lg
xl
2xl
The skeleton configuration can additionally be grouped as:
mobile → xs / sm
tablet → md
desktop → lg / xl / 2xl
all → everything
This means the component does not need to know the actual pixel values at all.
If Discourse changes the breakpoint values, the skeleton component doesn’t have to be rewritten around new hardcoded numbers.
Geometry caching
Selectors tell us what should be rendered, but they don’t tell us exactly where the skeleton shapes should appear.
For that, I added geometry capture.
The builder can measure the actual rendered regions and store their geometry so the loader can render a destination skeleton immediately during SPA navigation.
There is also an explicit geometry lock option for cases where I don’t want later visits to continuously change the reference geometry.
This was another important distinction:
selector definition and rendered geometry are two different things.
Drafts
Another thing that became necessary was draft state.
I didn’t want this workflow:
open builder
→ spend 10 minutes configuring it
→ close builder
→ everything is gone
The builder therefore keeps an in-progress draft separately from the actual theme setting.
The draft is scoped to the page/route/viewport combination, so for example:
topic-list / lg
topic-list / md
topic-list / xs
don’t accidentally overwrite one another.
Closing the builder does not destroy the work.
Undo
Once the builder became more interactive, an Undo system became almost unavoidable.
The builder stores snapshots of its configuration state:
{
"regions": \[\],
"excludes": \[\]
}
rather than trying to maintain a history of DOM operations.
That makes the undo system much easier to reason about and also keeps it independent from the actual page DOM.
This project is in active development. Hopefully soon ready for a theme component! ![]()




