Summary#
A component that reads data has a moment before that data arrives. skeleton { } says what to draw in that moment:
the shape the content will take, at the size it will take, so the page is composed before it is filled.
It is an ordinary render tree — same grammar, same controls, same style props — held on the component beside
render { }. The runtime shows it while the component's first read is still out — a query that has never settled,
a live server call that has not answered, the row a page's address names, or an on mount body still waiting on the
server — and swaps it for the real tree the moment that read lands.
It works the same on a routed page as on a component inside one: a page paints at once and its reads fill it. Without a skeleton, a page paints its static parts — headings, labels, buttons — with its data regions empty until they fill, and the page assembles itself in front of the user as each piece arrives. That is not merely unpolished: content that appears late pushes what is already on screen, so a person reading — or aiming at a button — has it move under them. (A component whose reads include a live server call is the exception: its body is not drawn before the call answers, skeleton or not, because every line of it may read that answer — so without a skeleton it shows nothing in that moment.)
Signature#
component Name(<params>) {
live var <data> = …; // the read the skeleton stands in for
render { … } // the real tree
skeleton { … } // the stand-in — same grammar, no data reads
}At most one skeleton block per component, and it is optional everywhere.
Description#
Where it shows: on the page, and on any component that reads#
A page paints before its data. When you open a page — by a link inside the app, or by loading its address — the page is drawn at once and its reads answer into it. Nothing waits for the slowest read before the first paint, so a page whose data takes four seconds is on screen in the first moment, holding its shape, rather than appearing whole four seconds later. Components inside the page work the same way: each paints and then fills.
So put skeleton on whatever does the reading — the page itself, when its own live vars are what it waits for, or
the component inside it that holds the read. A component that reads nothing never shows a skeleton, because there is
nothing to wait for.
A page that loads in on mount holds its shape too#
A report is a server function. A page can hold its answer as a live value (live var view = Report();, which
holds the skeleton too), or read it once in on mount { view = Report(); }. That body is the page's first read, so while it is waiting on the server the page shows
its skeleton { }, exactly as it would for a query — and when the body ends, however it ends, the real tree replaces
it. A body that throws drops the skeleton too: the page's own error state lives in render, and a stand-in left on
screen would claim the data was still coming.
A mount body that never calls the server — it sets some state, it starts nothing — finishes within the first moment, so it never shows the skeleton at all.
A fast read never flashes one. The first paint waits a moment — about 150 ms — for the page's reads, and a page whose data is back by then is drawn straight with its content: the skeleton is never shown. Only a read slower than that gets the stand-in, which is exactly when a stand-in helps.
On a full page load the page comes in two parts. The server answers a first load, or a reload, with everything the
page is drawn from first and its rows after, in the same response — so a reload paints at once too, and its rows land
the same way they do after a click. A public page the server renders as HTML ([[ui-routing|[Render(SSR)]]]) is
the exception, deliberately: that HTML is sent with its content in it, because it is what a search engine reads.
One consequence worth knowing before you hit it: a skeleton on a composed child means that child needs
[[ui-composable|[Composable]]] when the page around it is public. Its own read stays gated either way — the
attribute is about being allowed to render inside an anonymous page, not about what it may read.
A live server call, and the row in the address, hold it too#
A live var whose value is a server function's answer — live var view = PlanFor(day); ([[ui-reactivity#server-call]])
— is a first read like a query: the skeleton shows until it has answered for the arguments it reads now. A call
whose argument is another call's answer (live var settings = account == null ? null : SettingsOrNull(account);)
answers twice — once with no account, once with it — and the skeleton waits for the second, so the body never draws
its empty state ("No locations yet") over data that is on its way.
A page whose address names a row (component OrderPage(Order order) on /orders/{order}) is not drawn
without that row, so it shows its skeleton while the row is read — rather than nothing — and the not-found page when
the row is not there.
To say it in the body as well — a disabled Save, a line that says the value could not be loaded — read
Live.Pending(view) and Live.Failed(view) ([[ui-reactivity#server-call-state]]).
It shows on the FIRST read, and never again#
The swap is driven by whether a query has ever settled — not by whether one is currently loading. Those differ exactly once, and it matters:
| First read, nothing on screen yet | the skeleton shows |
| A refetch, with the previous rows still on screen | the skeleton does not show |
Replacing rows the user is already reading with grey placeholders is a regression, not a loading state. For the in-flight feedback that a refetch or an action deserves, see Pending; for a read that came back refused or broken, see A failing query — that region reports the failure in place, and a skeleton would sit there forever pretending it was still coming.
A skeleton may not read the data it stands in for#
Reading a live var from inside skeleton { } is a compile error naming the member. This is not a style rule —
it is the block's defining condition. A skeleton renders precisely because that read has not arrived, so anything it
reads off it is empty by construction, and the placeholder would silently render nothing.
Params and plain var state are allowed, deliberately. Both are present the moment the component mounts, and a
skeleton that knows how many rows to draw is a better skeleton than one guessing three.
The check covers the two ways data reaches a render tree: element arguments and foreach sources. A data read
buried in an if condition is not caught — it renders as a false branch rather than as anything harmful.
Draw the shape, at the real size#
The bar to aim for is that nothing moves when the data arrives: the skeleton occupies the same box the loaded content will. A placeholder that is the wrong height is worse than none, because it promises a layout and then breaks it — the reflow it causes is the exact problem a skeleton exists to prevent.
In practice that means fixing the dimensions rather than letting a placeholder collapse: give each stand-in row the height its real row will have, and the container the gap it will have.
A skeleton is static by default. If you want the shimmer, it is an ordinary animation applied
with a style prop — the platform ships no privileged pulse, because the timing is a house-style decision.
The kit's parts#
using Osysharp.Ui; brings the pieces a stand-in is built from, so a skeleton is a few lines and looks the same in
every app:
| Part | Stands in for |
|---|---|
Skeleton(shape: …) | one line, a half line, a control, an avatar, or a block — shimmering |
SkeletonText() | a paragraph: three lines, the last one short |
SkeletonCard(title, lines: n) | a whole Card — its frame, padding and border, its title drawn for real when you know it, and n placeholder lines |
LoadingNote(text = "Loading…") | the words "Loading…", quietly: a status message a screen reader says at once, which appears on screen only once the wait has gone on long enough to notice (~300 ms after the page opened) |
The placeholders say WHAT is coming; LoadingNote says THAT it is coming — to someone who cannot see shapes, and to
someone who has been looking at grey blocks for long enough to wonder.
Sections that load on their own#
A page with several reads does not have to wait for the slowest. Put each read in the component that shows it, and
give that component its own skeleton { }: each section paints its stand-in at once and fills in when ITS read lands,
so the fast ones appear first and a slow chart does not hold up the table above it. A page-level skeleton is right when
the page has one read; a section-level one when it has several.
If a section's read comes back refused or broken, that section says so in place (A failing query) while the others carry on.
Testing it#
A loading state is hard to assert on because every UI verb waits for the page to finish. Ui.HoldServer() keeps the
server's answers back so a test can stand on the stand-in, and Ui.ReleaseServer() lets them through to prove what
fills in — see Ui.HoldServer.
It survives an export#
skeleton { } persists into the application model as a second render tree and is regenerated by the decompiler, so a
component exported and recompiled keeps it. Worth stating only because it did not always: the block was persisted and
read back by nothing for its whole first life, which is invisible in the output — the component still renders, just
with nothing on screen while its first read is in flight, which is the entire point of the block.
Examples#
A page that holds its shape while its rows load#
The page's own frame — its title and its toolbar — is static, so the skeleton draws it for real and stands in only for the rows, at their real height:
entity Invoice { [Required] string Number; decimal Amount; }
[Route("/invoices")]
[Render(CSR)]
[AllowAnonymous]
component InvoicesPage() {
live var invoices = Invoice.OrderBy(i => i.Number).ToList();
render {
Stack(gap: 3, p: 4) {
Text("Invoices", fontSize: "20px");
foreach (var i in invoices) { Text(i.Number, h: "20px"); }
}
}
skeleton {
Stack(gap: 3, p: 4) {
Text("Invoices", fontSize: "20px"); // static — the same words, in the same place
Box(h: "20px", w: "180px");
Box(h: "20px", w: "180px");
Box(h: "20px", w: "180px");
}
}
}A child that holds its shape while its rows load#
entity Order { [Required] string Reference; decimal Total; }
[Composable] // it is dropped into a public page; its own read stays gated
component RecentOrders() {
live var orders = Order.OrderByDescending(o => o.Total).ToList();
render {
Stack(gap: 2) {
foreach (var o in orders) { Text(o.Reference); }
}
}
skeleton {
// The same frame, with the row's real height — so nothing reflows when the rows arrive.
Stack(gap: 2) {
Box(h: "14px", w: "240px");
Box(h: "14px", w: "240px");
Box(h: "14px", w: "240px");
}
}
}
[Route("/orders")]
[Render(CSR)]
[AllowAnonymous]
component OrdersPage() {
render { RecentOrders(); }
}A page that reads a report in on mount, built from the kit's parts#
using Osysharp.Ui;
public sealed class TideTable { public int High; public int Low; }
[AllowAnonymous]
TideTable ReadTides() { return new TideTable { High = 2, Low = 2 }; }
[Route("/tides")]
[Render(CSR)]
[AllowAnonymous]
component TidesPage() {
TideTable tides = new TideTable();
on mount { tides = ReadTides(); } // the page's first read — its skeleton shows until it lands
skeleton {
Stack(gap: 4, p: 6) {
Text("Tides", fontSize: "20px");
SkeletonCard("Today", lines: 2);
LoadingNote();
}
}
render {
Stack(gap: 4, p: 6) {
Text("Tides", fontSize: "20px");
Card("Today") {
Stack(gap: 3) { Text($"{tides.High} high waters"); Text($"{tides.Low} low waters"); }
}
}
}
}Sizing the stand-in from a param#
A param is present at mount, so the caller can tell the skeleton how much to draw:
component RecentOrders(int rows = 3) {
live var orders = Order.OrderByDescending(o => o.Total).Take(rows).ToList();
render { foreach (var o in orders) { Text(o.Reference); } }
skeleton { foreach (var i in placeholders) { Box(h: "14px", w: "240px"); } } // `rows` and plain state are fine
}What the compiler refuses#
component RecentOrders() {
live var orders = Order.ToList();
render { foreach (var o in orders) { Text(o.Reference); } }
skeleton {
foreach (var o in orders) { Box(h: "14px"); } // ERROR: a `skeleton` block cannot read 'orders' —
} // it renders precisely while that data is still loading
}See also#
- Pending — the in-flight feedback for actions and refetches, which is the other waiting state.
- A failing query — when the read comes back refused or broken, that region says so in place.
- animation — keyframe motion, on entry, on exit, in a loop, or as it scrolls into view —
animationblocks, if you want the stand-in to shimmer. - [Composable] — presentational components in public pages — why a composed child needs the attribute to render inside a public page.
- Ui.HoldServer — hold the server in a UI test, to assert the stand-in and then what fills in.
- component — components,
render, and the members a skeleton may read.