08. skeleton card (placeholder shapes match the real layout)
A product card whose <?start>/<?end> placeholder uses skeleton boxes shaped like the final content — image area, title, price, two text lines, button — so there's no layout shift when the data arrives. Zero client JS.
Hint: server holds the response open for 2.5s, then flushes the late template. Refresh to see the skeleton again.
learn more
- Chrome blog: Declarative Partial Updates
- WICG explainer: patching — The marker / late-template pattern this page is built on.
- template-for polyfill
source
Everything the server runs for this example. Each file links to GitHub so you can copy or fork it.
handler.ts/app/src/examples/08-skeleton-card/handler.ts
import { sleep, streamingResponse } from "../../lib/streaming.ts";
import { loadShell, readSibling, tryStatic } from "../../lib/files.ts";
const SHELL = loadShell(import.meta.url);
const LATE = readSibling(import.meta.url, "late.html");
export default function handle(_req: Request, path: string): Response | Promise<Response> {
if (path === "/" || path === "/index.html") {
return streamingResponse(async (write) => {
await write(SHELL);
await sleep(2500);
await write(LATE);
});
}
return tryStatic(import.meta.url, path) ?? new Response("Not found", { status: 404 });
}
index.html/app/src/examples/08-skeleton-card/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>08 · skeleton card</title>
<link rel="stylesheet" href="/public/styles.css">
<link rel="stylesheet" href="/08/styles.css">
</head>
<body>
<main>
<p class="crumbs"><a href="/">← back to index</a></p>
<h1>08. skeleton card (placeholder shapes match the real layout)</h1>
<p class="lede">A product card whose <code><?start>/<?end></code> placeholder uses skeleton boxes shaped like the final content — image area, title, price, two text lines, button — so there's no layout shift when the data arrives. Zero client JS.</p>
<section class="store-shell">
<div class="product-card">
<?start name="user-data">
<div class="skeleton sk-image"></div>
<div class="skeleton sk-title"></div>
<div class="skeleton sk-price"></div>
<div class="skeleton sk-line"></div>
<div class="skeleton sk-line short"></div>
<div class="skeleton sk-button"></div>
<?end>
</div>
</section>
<p style="color:var(--muted);font-size:.85rem;">Hint: server holds the response open for 2.5s, then flushes the late template. Refresh to see the skeleton again.</p>
<section class="refs">
<h3>learn more</h3>
<ul>
<li><a href="https://developer.chrome.com/blog/declarative-partial-updates?hl=en" target="_blank" rel="noopener">Chrome blog: Declarative Partial Updates</a></li>
<li><a href="https://github.com/WICG/declarative-partial-updates/blob/main/patching-explainer.md" target="_blank" rel="noopener">WICG explainer: patching</a> <span class="note">— The marker / late-template pattern this page is built on.</span></li>
<li><a href="https://github.com/GoogleChromeLabs/template-for-polyfill" target="_blank" rel="noopener">template-for polyfill</a></li>
</ul>
</section>
<!-- source-viewer goes here -->
<footer class="byline">made by <a href="https://paul.kinlan.me/" target="_blank" rel="noopener">Paul Kinlan</a></footer>
</main>
late.html/app/src/examples/08-skeleton-card/late.html
<template for="user-data">
<div class="product-image">PK</div>
<h2>Paul Kinlan</h2>
<p class="price">$99 / hr</p>
<p class="role">Chrome Developer Relations</p>
<p class="bio">Leads Chrome DevRel at Google. Likes the web, open standards, agents, and pubs in Liverpool.</p>
<button class="buy-btn">hire now</button>
</template>
</body>
</html>
styles.css/app/src/examples/08-skeleton-card/styles.css
.store-shell {
display: flex;
justify-content: center;
align-items: center;
padding: 1rem 0 2rem;
}
.product-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.5rem;
width: 100%;
max-width: 360px;
min-height: 460px;
display: flex;
flex-direction: column;
}
/* Skeleton shapes match the final layout, not generic boxes. */
.sk-image { height: 200px; width: 100%; margin-bottom: 1.25rem; border-radius: 8px; }
.sk-title { height: 1.75rem; width: 60%; margin-bottom: 0.75rem; }
.sk-price { height: 1.1rem; width: 28%; margin-bottom: 1rem; }
.sk-line { height: 0.85rem; width: 100%; margin-bottom: 0.55rem; }
.sk-line.short { width: 70%; }
.sk-button { height: 2.75rem; width: 100%; margin-top: auto; border-radius: 8px; }
/* Real-content styles match the skeleton shapes. */
.product-image {
height: 200px;
width: 100%;
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, var(--card) 40%));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
font-weight: 700;
color: var(--card);
margin-bottom: 1.25rem;
}
.product-card h2 { margin: 0 0 .25rem; font-size: 1.4rem; font-weight: 700; }
.product-card .price { color: var(--muted); font-size: 1.05rem; font-weight: 500; margin: 0 0 0.4rem; }
.product-card .role { color: var(--muted); font-size: 0.85rem; margin: 0 0 0.75rem; }
.product-card .bio { font-size: 0.9rem; line-height: 1.5; color: var(--fg); margin: 0 0 1rem; }
.product-card .buy-btn {
margin-top: auto;
width: 100%;
padding: 0.85rem;
border-radius: 8px;
border: none;
background: var(--fg);
color: var(--bg);
font-weight: 600;
cursor: pointer;
}
@media (prefers-color-scheme: dark) {
.product-card .buy-btn { background: var(--accent); color: var(--bg); }
}
lib/streaming.ts/app/src/lib/streaming.ts
export class StreamAborted extends Error {
constructor() {
super("stream aborted by client");
this.name = "StreamAborted";
}
}
export type ChunkSource = (write: (chunk: string) => Promise<void>) => Promise<void>;
const encoder = new TextEncoder();
export function streamingResponse(
source: ChunkSource,
contentType = "text/html; charset=utf-8",
): Response {
let aborted = false;
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const write = async (chunk: string) => {
if (aborted) throw new StreamAborted();
try {
controller.enqueue(encoder.encode(chunk));
} catch {
aborted = true;
throw new StreamAborted();
}
};
try {
await source(write);
} catch (err) {
if (!(err instanceof StreamAborted)) console.error("stream source error", err);
} finally {
if (!aborted) {
try {
controller.close();
} catch {
// already closed
}
}
}
},
cancel() {
aborted = true;
},
});
return new Response(body, {
headers: {
"content-type": contentType,
"cache-control": "no-store",
"x-content-type-options": "nosniff",
},
});
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
lib/files.ts/app/src/lib/files.ts
// File helpers for examples: read sibling files, render the page shell with the
// source viewer injected, and serve sibling static assets (CSS/JS/HTML) on demand.
import { sourceBlock } from "./source.ts";
const MIME: Record<string, string> = {
css: "text/css; charset=utf-8",
js: "application/javascript; charset=utf-8",
html: "text/html; charset=utf-8",
svg: "image/svg+xml",
json: "application/json; charset=utf-8",
};
function dirFor(handlerUrl: string): string {
return new URL(".", handlerUrl).pathname;
}
export function readSibling(handlerUrl: string, name: string): string {
return Deno.readTextFileSync(dirFor(handlerUrl) + name);
}
// Reads a sibling HTML file. The marker `<!-- source-viewer goes here -->` is replaced
// with the source-viewer block. Extra named placeholders ({{name}}) can be supplied via `vars`.
const SOURCE_MARKER = "<!-- source-viewer goes here -->";
export function loadShell(
handlerUrl: string,
name = "index.html",
vars: Record<string, string> = {},
): string {
let raw = readSibling(handlerUrl, name);
raw = raw.replace(SOURCE_MARKER, sourceBlock(handlerUrl));
for (const [k, v] of Object.entries(vars)) {
raw = raw.replaceAll(`{{${k}}}`, v);
}
return raw;
}
// Try to serve a sibling static file from the example folder. Returns null when the
// file is missing, the path escapes the folder, or the path is empty.
export function tryStatic(handlerUrl: string, requestPath: string): Response | null {
const safe = requestPath.replace(/^\/+/, "");
if (!safe || safe.includes("..")) return null;
// Never let the browser fetch handler.ts or README.md through this fallback.
if (safe === "handler.ts" || safe === "README.md") return null;
try {
const content = Deno.readFileSync(dirFor(handlerUrl) + safe);
const ext = safe.split(".").pop() ?? "";
return new Response(content, {
headers: {
"content-type": MIME[ext] ?? "application/octet-stream",
"cache-control": "no-store",
},
});
} catch {
return null;
}
}