@lukas_holdings/castdom
v1.0.4
Published
Pixel-perfect skeleton loading screens, extracted from your real DOM. Zero-config, SSR-first, CSS-only runtime.
Maintainers
Readme
Why CastDOM?
Every skeleton screen you've ever built was a lie. You eyeballed the widths, guessed the heights, and prayed the layout didn't shift. CastDOM fixes that.
It reads getBoundingClientRect() on every visible element in your component, stores the positions as a flat array of bones, and renders them as CSS-only rectangles that match your real layout exactly — at every breakpoint.
Your Component CastDOM Skeleton
+----------------------------------+ +----------------------------------+
| [====] [O] | | [####] [O] |
| | | |
| Some heading text | | [################] |
| A longer paragraph that wraps | | [##########################] |
| across multiple lines of text | | [########################] |
| | | |
| [ Button ] | | [##########] |
+----------------------------------+ +----------------------------------+
Real DOM Extracted SkeletonKey Advantages
- Pixel-perfect — bones are extracted from your actual rendered DOM, not approximated
- SSR-first — skeletons render server-side as real HTML. Crawlers see them. Zero CLS
- CSS-only runtime — no JavaScript needed to display skeletons. Just CSS animations
- Content-aware — detects text, headings, images, avatars, buttons, and inputs
- Responsive — captures at multiple breakpoints (375px, 768px, 1280px by default)
- Compressed — delta-encoded bone data is 60-70% smaller than raw JSON
- Accessible —
role="status",aria-busy,aria-label,prefers-reduced-motion - Framework-native — adapters for Next.js, Astro, Vite, and vanilla JS
- Zero runtime dependencies — the client bundle is pure CSS + tiny registry
Quick Start
1. Install
npm install @lukas_holdings/castdom2. Mark your components
Add data-castdom attributes to the components you want skeletons for:
<div data-castdom="user-card">
<img class="avatar" src="..." />
<h2>Jane Doe</h2>
<p>Software Engineer at Acme Corp</p>
<button>Follow</button>
</div>3. Configure
npx castdom initThis creates castdom.config.json:
{
"devServer": "http://localhost:3000",
"outDir": ".castdom",
"breakpoints": [375, 768, 1280],
"color": "#e0e0e0",
"shimmerColor": "#f0f0f0",
"animationDuration": 1500,
"contentAware": true,
"targets": [
{
"name": "user-card",
"selector": "[data-castdom=\"user-card\"]",
"route": "/"
}
]
}4. Extract
Start your dev server, then:
npx castdom buildCastDOM Build
Server: http://localhost:3000
Breakpoints: 375, 768, 1280px
Targets: 1
Output: .castdom
Skeletons: 1
Bones: 12
Raw size: 2.1 KB
Compressed: 0.8 KB (62% smaller)
Files: 8
Total time: 1240ms5. Use
// Import the loader once at your app entry
import ".castdom/loader.js";
// Use anywhere
import { CastDOM } from "@lukas_holdings/castdom/react";
function UserProfile({ userId }) {
const { data, isLoading } = useFetch(`/api/users/${userId}`);
return (
<CastDOM name="user-card" loading={isLoading}>
<UserCard data={data} />
</CastDOM>
);
}That's it. The skeleton matches your real layout perfectly, at every screen size.
How It Works
Extraction Pipeline
Dev Server Playwright Extractor Compiler
| | | |
| load page | | |
|<------------------| | |
| set viewport | | |
|<------------------| | |
| | inject script | |
| |------------------->| |
| | | walk DOM tree |
| | | getBoundingRect |
| | | detect kinds |
| | bone data | |
| |<-------------------| |
| | | |
| | compile, compress, codegen |
| |-------------------------------------->|
| | | |
| | | .castdom/
| | | manifest.json
| | | castdom.css
| | | loader.js
| | | index.d.tsBone Data Model
Each element becomes a bone — a minimal rectangle descriptor:
interface Bone {
x: number; // X position relative to container
y: number; // Y position relative to container
w: number; // Width
h: number; // Height
r: number; // Border radius (9999 = circle)
kind?: BoneKind; // "text" | "heading" | "image" | "avatar" | "button" | ...
}Content-Aware Detection
CastDOM doesn't just make rectangles. It detects what each element is:
| Element | Detection | Skeleton Shape |
|---------|-----------|----------------|
| <h1>-<h6> | Tag name | Rounded rectangle, heading height |
| <p>, text nodes | Text content check | Multiple line rectangles |
| <img> | Tag name + aspect ratio | Rectangle with image proportions |
| Avatar | Small + border-radius: 50% | Circle |
| <button> | Tag or role="button" | Pill shape |
| <input> | Tag name | Bordered rectangle |
| <hr> | Tag name | Thin divider line |
Compression
Bone data is compressed using delta encoding and zigzag varint packing:
Raw JSON: 2,100 bytes
Compressed: 800 bytes (62% smaller)
Base64: 540 bytes (74% smaller)The compression pipeline:
- Sort bones top-to-bottom, left-to-right
- Delta-encode X/Y positions
- Quantize to half-pixels
- Zigzag encode signed integers
- Variable-length integer packing
React
<CastDOM> Component
import { CastDOM } from "@lukas_holdings/castdom/react";
<CastDOM
name="user-card" // Registered skeleton name
loading={isLoading} // Show skeleton when true
animation="shimmer" // "shimmer" | "pulse" | "wave" | "none"
color="#e0e0e0" // Base bone color
shimmerColor="#f0f0f0" // Shimmer highlight color
duration={1500} // Animation duration (ms)
className="my-skeleton" // Additional CSS class
onSkeletonShow={() => console.log("Skeleton visible")}
onContentShow={() => console.log("Content rendered")}
ariaLabel="Loading user profile"
>
<UserCard data={data} />
</CastDOM><CastDOMStyle> Component
Add to your <head> for critical CSS:
import { CastDOMStyle } from "@lukas_holdings/castdom/react";
function Layout({ children }) {
return (
<html>
<head>
<CastDOMStyle />
</head>
<body>{children}</body>
</html>
);
}useCastDOM Hook
import { useCastDOM } from "@lukas_holdings/castdom/react";
function CustomSkeleton() {
const { exists, data, breakpoint, css, html } = useCastDOM("user-card");
if (!exists) return <FallbackSkeleton />;
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}Next.js
App Router
// app/layout.tsx
import { initCastDOM } from "@lukas_holdings/castdom/next";
import manifest from "../.castdom/manifest.json";
initCastDOM(manifest);
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
);
}// app/dashboard/page.tsx
import { CastDOM } from "@lukas_holdings/castdom/next";
export default async function Dashboard() {
const data = await fetchDashboard();
return (
<CastDOM name="dashboard" loading={!data}>
<DashboardContent data={data} />
</CastDOM>
);
}Pages Router
// pages/_app.tsx
import { initCastDOM } from "@lukas_holdings/castdom/next";
import manifest from "../.castdom/manifest.json";
initCastDOM(manifest);
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />;
}Server-Side Props
import { getSkeletonProps } from "@lukas_holdings/castdom/next";
export async function getServerSideProps() {
const { skeletonHTML, skeletonCSS } = getSkeletonProps(["user-card"]);
return { props: { skeletonHTML, skeletonCSS } };
}Auto-Generated loading.tsx
After npx castdom build, a ready-to-use loading.tsx is generated at .castdom/nextjs-loading.tsx. Copy it to your route:
cp .castdom/nextjs-loading.tsx app/dashboard/loading.tsxAstro
Integration
// astro.config.mjs
import { defineConfig } from "astro/config";
import { castdomIntegration } from "@lukas_holdings/castdom/astro";
export default defineConfig({
integrations: [castdomIntegration()],
});Component Usage
---
import { skeleton } from "@lukas_holdings/castdom/astro";
const { html, css } = skeleton("user-card");
---
<style set:html={css}></style>
<div data-castdom="user-card">
<Fragment set:html={html} />
<slot />
</div>View Transitions
---
import { skeleton, viewTransitionProps } from "@lukas_holdings/castdom/astro";
const { html } = skeleton("sidebar");
const transitionProps = viewTransitionProps("sidebar");
---
<div {...transitionProps}>
<Fragment set:html={html} />
</div>Vite
// vite.config.ts
import { defineConfig } from "vite";
import { castdom } from "@lukas_holdings/castdom/vite";
export default defineConfig({
plugins: [castdom()],
});Then import the virtual module:
import "virtual:castdom";The plugin provides:
- HMR — hot-reload when skeleton data changes
- Virtual module —
virtual:castdomauto-loads the manifest - Dev middleware —
/__castdom/extractendpoint
Vanilla JS
For non-framework usage:
import { createCastDOM } from "@lukas_holdings/castdom";
import manifest from ".castdom/manifest.json";
const castdom = createCastDOM();
castdom.loadManifest(manifest);
// Show skeleton
const container = document.getElementById("user-card");
castdom.show("user-card", container);
// Later, hide and show real content
castdom.hide("user-card", container);SSR & SEO
CastDOM is SSR-first. Skeletons render as server-side HTML with zero JavaScript.
Why This Matters for SEO
| Metric | Without CastDOM | With CastDOM |
|--------|-----------------|--------------|
| CLS | Layout shifts when content loads | Zero — skeletons match exact dimensions |
| FCP | Blank until JS loads | Instant — CSS-only skeletons |
| LCP | Delayed by data fetching | Skeleton paints immediately |
| Accessibility | No loading indication | role="status", aria-busy, screen reader labels |
| Crawler visibility | Empty containers | Structured placeholders with ARIA |
Server-Side Rendering
import { renderSkeleton, renderSSRFragment } from "@lukas_holdings/castdom/ssr";
// Single skeleton
const html = renderSkeleton(skeletonData);
// Includes: <style> + skeleton HTML + hydration data attributes
// Multiple skeletons (shared CSS)
const { head, body } = renderSSRFragment([skeleton1, skeleton2]);
// head: <style> + <script> hydration
// body: { "user-card": "...", "feed-item": "..." }Hydration
SSR skeletons include data-castdom-ssr attributes. A tiny (~200 byte) inline hydration script automatically removes them once real content renders:
import { renderHydrationScript } from "@lukas_holdings/castdom/ssr";
const script = renderHydrationScript();
// <script data-castdom="hydration">(...)</script>Critical CSS
import { renderCriticalStyleTag } from "@lukas_holdings/castdom/ssr";
const styleTag = renderCriticalStyleTag([skeleton1, skeleton2]);
// <style data-castdom="critical">@keyframes castdom-shimmer{...}...</style>CLI Reference
castdom build [options] # Extract skeletons and generate files
castdom init # Create castdom.config.json template
castdom list # List skeletons in the manifest
castdom clean # Remove generated output directoryBuild Options
| Flag | Default | Description |
|------|---------|-------------|
| --url <url> | http://localhost:3000 | Dev server URL |
| --config <path> | castdom.config.json | Config file path |
| --out <dir> | .castdom | Output directory |
| --breakpoints <list> | 375,768,1280 | Comma-separated breakpoints |
| --no-headless | false | Show browser UI (for debugging) |
| --verbose | false | Detailed progress output |
Configuration
castdom.config.json
{
"devServer": "http://localhost:3000",
"outDir": ".castdom",
"breakpoints": [375, 768, 1280],
"color": "#e0e0e0",
"shimmerColor": "#f0f0f0",
"animationDuration": 1500,
"contentAware": true,
"minBoneSize": 4,
"targets": [
{
"name": "user-card",
"selector": "[data-castdom=\"user-card\"]",
"route": "/"
},
{
"name": "feed-item",
"selector": "[data-castdom=\"feed-item\"]",
"route": "/feed"
}
]
}| Option | Type | Default | Description |
|--------|------|---------|-------------|
| devServer | string | http://localhost:3000 | URL of your dev server |
| outDir | string | .castdom | Where to write generated files |
| breakpoints | number[] | [375, 768, 1280] | Viewport widths to capture |
| color | string | #e0e0e0 | Base bone color |
| shimmerColor | string | #f0f0f0 | Shimmer highlight color |
| animationDuration | number | 1500 | Animation cycle duration (ms) |
| contentAware | boolean | true | Detect element types for shaped bones |
| minBoneSize | number | 4 | Skip elements smaller than this (px) |
Targets
Each target defines a component to extract:
| Field | Required | Description |
|-------|----------|-------------|
| name | Yes | Unique skeleton identifier |
| selector | Yes | CSS selector for the container element |
| route | No | Page route to navigate to before extracting |
Animation Types
CastDOM supports four animation types, all CSS-only:
Shimmer (default)
A gradient sweep from left to right. Classic skeleton animation.
<CastDOM name="card" animation="shimmer" />Pulse
Opacity fade between 100% and 40%.
<CastDOM name="card" animation="pulse" />Wave
Staggered opacity animation — each bone animates with a 50ms delay.
<CastDOM name="card" animation="wave" />None
Static gray rectangles. Also used automatically when prefers-reduced-motion: reduce is active.
<CastDOM name="card" animation="none" />API Reference
Core
import {
loadManifest, // Register all skeletons from generated manifest
register, // Register a single skeleton
get, // Get skeleton entry by name
has, // Check if skeleton exists
names, // List all registered skeleton names
configure, // Set global config (colors, animation, etc.)
getAllCSS, // Get combined CSS for all registered skeletons
extractBones, // Extract bones from a DOM element (browser-side)
compressBones, // Compress bone data for storage
decompressBones, // Decompress bone data
} from "@lukas_holdings/castdom";React
import {
CastDOM, // Wrapper component
CastDOMStyle, // Critical CSS component for <head>
useCastDOM, // Hook for programmatic access
} from "@lukas_holdings/castdom/react";SSR
import {
renderSkeleton, // Render skeleton to HTML string
renderSkeletons, // Render multiple with shared CSS
renderCriticalStyleTag, // Generate <style> tag
renderHydrationScript, // Generate hydration <script>
renderSSRFragment, // Complete head + body fragments
} from "@lukas_holdings/castdom/ssr";Generated Output
After running npx castdom build, the .castdom/ directory contains:
.castdom/
manifest.json # All skeleton data (register with loadManifest)
castdom.css # Critical CSS for all skeletons
index.js # ESM module with tree-shakeable exports
index.d.ts # TypeScript declarations
loader.js # Auto-registers all skeletons on import
nextjs-loading.tsx # Ready-to-use Next.js loading component
skeletons/
user-card.json # Individual skeleton data
user-card.js # Individual ESM export
feed-item.json
feed-item.jsTypeScript
CastDOM is written in TypeScript and ships full type declarations. After extraction, skeleton names are typed:
// .castdom/index.d.ts (auto-generated)
export type SkeletonName = "user-card" | "feed-item";AI-Friendly
CastDOM ships CASTDOM.md — a comprehensive context file for AI coding assistants (Claude, Gemini, GPT, Cursor, Copilot, Windsurf, etc.). It contains every type, API, integration pattern, and CLI command so any AI can help you integrate CastDOM correctly.
Browser Support
CastDOM's runtime is pure CSS — it works everywhere CSS animations work:
- Chrome 60+
- Firefox 60+
- Safari 12+
- Edge 79+
The extraction tool (CLI) requires Node.js 18+ and Playwright.
License
MIT
