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.

1
Scan
2
Launch
3
Analyze
4
Generate
5
Write

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 name prop — the unique identifier for each hollow
  • The empty condition expression
  • The file path and line number for source mapping
  • Any sibling components that provide layout context
src/components/inbox.tsx
tsx
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:

Internal manifest (in-memory)
json
{  "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.

Internal — browser launch
typescript
// 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.

CategorySignalsExample
listRepeated child elements, scroll container, ul/ol/tableInbox, task list, feed
searchInput with search icon, filter controls, query paramsSearch results, filtered view
dashboardGrid layout, card containers, metric widgetsAnalytics, overview panel
detailSingle entity view, back navigation, ID in routeProfile, invoice detail
uploadDrop zone, file input, drag eventsFile 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.

Analysis output (per hollow)
json
{  "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:

  1. 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."
  2. Illustration — An SVG illustration selected from the built-in library (or generated) that matches the category and theme.
  3. Layout — CSS properties computed to fit the container: alignment, padding, max-width for text, illustration size.
Generated empty state
json
{  "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/:

Output directory
bash
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.svg

The 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.

app/layout.tsx
tsx
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.

Internal — AST visitor
typescript
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.

Internal — classifier
typescript
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:

Manual category override
tsx
<Hollow name="recent-files" category="upload" empty={files.length === 0}>  <FileGrid files={files} /></Hollow>