Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

skeleton

component X(…) { render { … } skeleton { … } } — the shape a component holds while its FIRST read is in flight

A second render tree that stands in for a page or a component while its first read — its queries, its live server calls, the row a page's address names, and an `on mount` that calls the server — has not yet answered. A page paints at once and its reads fill it; the skeleton is what it shows meanwhile. It is written with the same grammar as `render`, it may not read the data it stands in for, it is skipped when the read answers within a moment, and it gives way the moment the first answer lands — never again on a refetch. The UI kit has the parts: `Skeleton`, `SkeletonText`, `SkeletonCard` and a quiet `LoadingNote`.

stable3 examples compiled by CIuiloadingfeedbackcomponent

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 yetthe skeleton shows
A refetch, with the previous rows still on screenthe 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:

PartStands 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#

Related

Pending

When a control's action waits on the server, the platform shows a busy spinner and disables the control — but only…

A failing query

When a query a region reads fails or is refused, that region shows the failure in place — the server's own sentence…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

animation — keyframe motion, on entry, on exit, in a loop, or as it scrolls into view

An `animation` block declares reusable keyframe motion — an entrance, a shimmer, a pulse, a flash that decays. Its…

[Composable] — presentational components in public pages

Mark a presentational, composition-only component `[Composable]` so a public page can compose it without marking it…

Ui.HoldServer

In a UI test, keeps the server's answers to the page's reads back until `Ui.ReleaseServer()`, so the test can stand on…