← back to index

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

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 });
}

open on GitHub →

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="/">&larr; back to index</a></p>
  <h1>08. skeleton card (placeholder shapes match the real layout)</h1>
  <p class="lede">A product card whose <code>&lt;?start&gt;/&lt;?end&gt;</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>

open on GitHub →

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>

open on GitHub →

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); }
}

open on GitHub →

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));
}

open on GitHub →

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;
  }
}

open on GitHub →