How It Works
Hollows UI uses a 5-step build pipeline to scan your components, analyze their DOM structure, classify their purpose, generate contextual copy, and write production-ready empty state assets.
Pipeline overview
When you run npx hollows-ui build, the CLI executes a deterministic pipeline that converts your source code into ready-to-use empty state definitions. No AI is used at runtime — everything is pre-computed at build time.
Step 1 — Scan
The AST scanner walks your source tree looking for files that import and use the <Hollow> component. It parses each file into an abstract syntax tree using SWC, then extracts:
- The
nameprop — the unique identifier for each hollow - The
emptycondition expression - The file path and line number for source mapping
- Any sibling components that provide layout context
import { Hollow } from 'hollows-ui/react'export function Inbox({ messages }) { return ( <div className="inbox-container"> <Hollow name="user-inbox" empty={messages.length === 0}> <MessageList items={messages} /> </Hollow> </div> )}The scanner outputs a manifest of all discovered hollows with their metadata:
{ "hollows": [ { "name": "user-inbox", "filePath": "src/components/inbox.tsx", "line": 6, "emptyExpr": "messages.length === 0", "siblings": ["MessageList"] } ]}Step 2 — Launch
Hollows spins up a headless Playwright browser and loads your application with each hollow in its populated state. This allows the tool to capture the real rendered DOM — including styles, dimensions, and layout — rather than guessing from source code alone.
// hollows-ui starts a dev server if one isn't runningconst browser = await playwright.chromium.launch({ headless: true })const page = await browser.newPage()for (const hollow of manifest.hollows) { // Navigate to the route containing this hollow await page.goto(hollow.resolvedUrl) // Wait for the hollow's container to be visible await page.waitForSelector(`[data-hollow="${hollow.name}"]`) // Capture DOM snapshot + computed styles const snapshot = await page.evaluate(captureHollowContext, hollow.name) hollow.domSnapshot = snapshot}The browser step is what makes hollows different from static analysis tools. By rendering the actual component, hollows can see the true layout dimensions, CSS styles, font sizes, neighboring elements, and overall page context.
Step 3 — Analyze
With the DOM snapshot in hand, the analyzer classifies each hollow into one of the built-in component categories. It uses a combination of heuristics and structural pattern matching.
| Category | Signals | Example |
|---|---|---|
| list | Repeated child elements, scroll container, ul/ol/table | Inbox, task list, feed |
| search | Input with search icon, filter controls, query params | Search results, filtered view |
| dashboard | Grid layout, card containers, metric widgets | Analytics, overview panel |
| detail | Single entity view, back navigation, ID in route | Profile, invoice detail |
| upload | Drop zone, file input, drag events | File manager, gallery |
The analyzer also extracts dimensional context: how much space is available, whether the hollow sits inside a sidebar or main content area, what the surrounding color palette looks like, and whether the container has padding or a card-style border.
{ "name": "user-inbox", "category": "list", "context": { "width": 640, "height": 480, "parentTag": "div", "hasHeader": true, "hasSidebar": false, "bgColor": "#0a0a0c", "fontFamily": "Inter, sans-serif" }, "siblings": ["InboxHeader", "InboxFilters"]}Step 4 — Generate
Using the classification and layout context, hollows generates three things for each detected empty state:
- Copy — A headline, description, and CTA label that match the component's purpose. A list component gets "No messages yet," while a search component gets "No results found."
- Illustration — An SVG illustration selected from the built-in library (or generated) that matches the category and theme.
- Layout — CSS properties computed to fit the container: alignment, padding, max-width for text, illustration size.
{ "name": "user-inbox", "category": "list", "copy": { "headline": "No messages yet", "description": "When you receive messages, they'll appear here.", "cta": "Compose a message" }, "illustration": "inbox-empty", "layout": { "align": "center", "padding": "48px 24px", "maxWidth": "360px", "illustrationSize": 180 }}Step 5 — Write
Finally, hollows writes the generated assets to your project. By default, output goes to src/hollows/:
rc/hollows/ .hollows.json # All empty state definitions registry.js # Auto-import file for your app entry illustrations/ inbox-empty.svg # Generated SVG illustrations search-empty.svg dashboard-empty.svgThe registry.js file is a side-effect import that registers all generated empty states with the runtime. You import it once in your app entry point and every <Hollow> component auto-resolves its matching definition by name.
import './hollows/registry'export default function RootLayout({ children }) { return <html><body>{children}</body></html>}AST scanner details
The scanner uses SWC to parse TypeScript and JSX. It walks the AST looking for JSX elements named Hollow (or whatever you configure) and extracts props from the opening element.
import { parse } from '@swc/core'async function scanFile(filePath: string) { const source = await fs.readFile(filePath, 'utf-8') const ast = await parse(source, { syntax: 'typescript', tsx: true, }) const hollows: HollowEntry[] = [] visit(ast, { JSXOpeningElement(node) { if (getElementName(node) === 'Hollow') { const name = getStringProp(node, 'name') const emptyExpr = getPropExpression(node, 'empty') if (name) { hollows.push({ name, emptyExpr, filePath, line: node.span.start, }) } } }, }) return hollows}Classification model
The classifier uses a weighted scoring model to determine the component category. Each signal contributes a score, and the category with the highest total wins.
interface Signal { category: string weight: number test: (snapshot: DOMSnapshot) => boolean}const signals: Signal[] = [ { category: 'list', weight: 3, test: (s) => s.repeatedChildren > 2, }, { category: 'list', weight: 2, test: (s) => s.hasScrollContainer, }, { category: 'search', weight: 4, test: (s) => s.hasSearchInput, }, { category: 'search', weight: 2, test: (s) => s.hasFilterControls, }, { category: 'dashboard', weight: 3, test: (s) => s.gridColumns > 1, }, { category: 'upload', weight: 5, test: (s) => s.hasDropZone, },]function classify(snapshot: DOMSnapshot): string { const scores: Record<string, number> = {} for (const signal of signals) { if (signal.test(snapshot)) { scores[signal.category] = (scores[signal.category] || 0) + signal.weight } } return Object.entries(scores) .sort(([, a], [, b]) => b - a)[0]?.[0] ?? 'generic'}You can override the classification for any hollow using the category prop:
<Hollow name="recent-files" category="upload" empty={files.length === 0}> <FileGrid files={files} /></Hollow>