skeleton-loading-system
Comprehensive guide for PharmaFlow AI's progressive skeleton loading architecture. Use when creating new pages, adding loading states, building skeleton components, or debugging loading UI issues. Covers the 4-tier loading hierarchy, design tokens, anti-patterns, and step-by-step checklists for addi
Install
mkdir -p .claude/skills/skeleton-loading-system && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/15527" && unzip -o skill.zip -d .claude/skills/skeleton-loading-system && rm skill.zipInstalls to .claude/skills/skeleton-loading-system
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Comprehensive guide for PharmaFlow AI's progressive skeleton loading architecture. Use when creating new pages, adding loading states, building skeleton components, or debugging loading UI issues. Covers the 4-tier loading hierarchy, design tokens, anti-patterns, and step-by-step checklists for adding skeletons to new features.About this skill
Skeleton Loading System — Architecture & Implementation Guide
Philosophy: The UI should feel "already rendered" from the first frame. No spinners. No blank screens. No layout shift. Every pixel has a placeholder before data arrives.
1. Architecture Overview
PharmaFlow AI uses a 4-tier progressive loading hierarchy. Each tier handles a different lifecycle stage:
┌─────────────────────────────────────────────────────────────────────┐
│ TIER 0 — App Bootstrap (Auth + Onboarding Check) │
│ └─ Full-screen spinner on black bg (the ONLY spinner allowed) │
│ File: App.tsx │
├─────────────────────────────────────────────────────────────────────┤
│ TIER 1 — Page Transition Skeleton (Blocking / Full-page) │
│ └─ PageSkeletonRegistry → reads PAGE_REGISTRY[view].skeleton │
│ Orchestrator: PageRouter.tsx │
│ Registry: components/skeletons/PageSkeletonRegistry.tsx │
├─────────────────────────────────────────────────────────────────────┤
│ TIER 2 — Section-Level Skeletons (Non-blocking / Progressive) │
│ └─ Parent passes isLoading prop to children │
│ Children swap their content with imported skeleton components │
├─────────────────────────────────────────────────────────────────────┤
│ TIER 3 — Inline Element Skeletons (Component-internal) │
│ └─ Component handles loading internally via isLoading prop │
│ No separate skeleton component, just conditional className │
└─────────────────────────────────────────────────────────────────────┘
Decision Tree: Which Tier to Use?
Is it the initial app boot / auth check?
→ TIER 0 (spinner — already implemented, don't touch)
Is data loaded per-page and the ENTIRE page depends on it?
→ TIER 1 (register a skeleton in PAGE_REGISTRY)
Does only a SECTION of the page depend on async data?
→ TIER 2 (create skeleton components, import in section)
Is it a SINGLE element (card, value, badge) that loads?
→ TIER 3 (handle inline with isLoading prop + conditional classes)
2. Tier 1 — Page Transition Skeletons
How It Works
App.tsxpassesisLoading(fromDataContext) toPageRouterPageRouterchecks: isisLoading === true? Is the current view NOT in the exclusion list?- If yes → renders
<PageSkeletonRegistry view={view} /> PageSkeletonRegistrylooks upPAGE_REGISTRY[view].skeletonand renders it
The Exclusion List (PageRouter)
Some pages handle loading internally (Tier 2 pattern) and skip the blocking skeleton:
// These pages render their OWN progressive skeletons
if (
isLoading &&
view !== 'inventory' &&
view !== 'customers' &&
// ... other pages that handle loading internally
) {
return <PageSkeletonRegistry view={view} />;
}
Rule: If a page handles its own loading, add it to this exclusion list.
PageConfig Type (pageRegistry.ts)
interface PageConfig {
id: string;
component: ComponentType<any>;
skeleton?: ComponentType<any>; // ← Tier 1 skeleton
skeletonProps?: Record<string, any>; // ← Optional props for skeleton
// ... other fields
}
Registration Example
// In config/pageRegistry.ts
'my-page': {
id: 'my-page',
component: MyPageComponent,
skeleton: MyPageSkeleton, // ← The skeleton component
skeletonProps: { withTopBar: true }, // ← Props forwarded to skeleton
permission: 'some.permission',
layout: 'dashboard',
},
The PageSkeletonRegistry (Orchestrator)
// components/skeletons/PageSkeletonRegistry.tsx
export const PageSkeletonRegistry = ({ view }: { view: string }) => {
const config = PAGE_REGISTRY[view];
const Skeleton = config?.skeleton || (() => null); // Fallback to empty
const skeletonProps = config?.skeletonProps || {};
return (
<div className='animate-fade-in transition-opacity duration-300'>
<Skeleton {...skeletonProps} />
</div>
);
};
3. Tier 2 — Section-Level Skeletons
The Pattern
// Parent Page Component
const [isLoading, setIsLoading] = useState(true);
// 1. Fetch data
useEffect(() => { fetchData().finally(() => setIsLoading(false)) }, []);
// 2. Pass isLoading to ALL children (single source of truth)
return (
<>
<PageHeader ... /> {/* ← Always renders immediately */}
<ChildSection isLoading={isLoading} data={data} />
<AnotherSection isLoading={isLoading} data={data} />
</>
);
// Child Component
import { ItemRowSkeleton } from '../skeletons/MyModuleSkeletons';
export const ListSection = ({ items, isLoading }) => {
return (
<div className={`p-5 rounded-3xl ${CARD_BASE}`}>
<h3>Section Title</h3> {/* ← Header always visible */}
<div>
{isLoading ? (
<>
{[1, 2, 3, 4].map(i => <ItemRowSkeleton key={i} />)}
</>
) : items.length === 0 ? (
<EmptyState />
) : (
items.map(item => <ItemRow key={item.id} item={item} />)
)}
</div>
</div>
);
};
Key Rules
- Single source of truth:
isLoadingstate lives in the parent page, never in children - Header always renders: The page shell (header, navigation tabs) never shows skeletons
- Ternary pattern: Always
isLoading ? <Skeleton /> : isEmpty ? <Empty /> : <Content /> - Count matters: Render a realistic number of skeleton items (e.g., 4 rows, not 1 or 20)
4. Tier 3 — Inline Element Skeletons
The Pattern
No separate skeleton component. The component itself has conditional rendering based on isLoading:
// A component that handles its own skeleton internally
export const MetricCard = ({ title, value, icon, iconColor, isLoading }) => {
return (
<div className={`p-3 rounded-2xl ${CARD_BASE}`}>
{/* Icon — swaps to gray box when loading */}
<div className={`w-12 h-12 rounded-xl ${
isLoading
? 'bg-zinc-100 dark:bg-zinc-800 animate-pulse'
: `text-${iconColor}-600`
}`}>
{!isLoading && <span className="material-symbols-rounded">{icon}</span>}
</div>
{/* Title — always visible (static content) */}
<p className="text-xs">{title}</p>
{/* Value — swaps to gray bar when loading */}
{isLoading ? (
<div className="h-8 w-20 bg-zinc-100 dark:bg-zinc-800 rounded-lg animate-pulse" />
) : (
<h4>{value}</h4>
)}
</div>
);
};
Data Tables (Built-in Skeleton Rows)
Tables should generate their own skeleton rows based on column definitions:
{isLoading && rows.length === 0 ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={`skeleton-row-${i}`} className="animate-pulse border-b">
{table.getAllColumns().map(col => (
<td key={`skeleton-cell-${col.id}-${i}`} className="py-4 px-4">
<div className="h-4 bg-zinc-100 dark:bg-zinc-800 rounded" />
</td>
))}
</tr>
))
)}
Rule: If a reusable common component supports
isLoading, prefer using it over creating a separate skeleton.
5. Design Tokens & Visual Standards
Color Palette for Skeleton Blocks
| Variant | Light Mode | Dark Mode | Usage |
|---|---|---|---|
| Primary block | bg-zinc-100 | dark:bg-zinc-800 | Icons, titles, main content areas |
| Secondary block | bg-zinc-50 | dark:bg-zinc-800/50 | Subtitles, badges, secondary info |
| Card container | bg-white | dark:bg-zinc-900 | Full card skeletons |
Standard palette: Always use
zincvariants for all skeletons. Do NOT mixgray/neutralcolors.
Border & Rounding
/* Cards */
border border-zinc-200 dark:border-zinc-800 rounded-xl /* Small cards */
border border-zinc-200 dark:border-zinc-800 rounded-3xl /* Large card containers */
/* Elements inside cards */
rounded /* Small text blocks */
rounded-lg /* Buttons, inputs */
rounded-full /* Badges, avatar circles */
rounded-xl /* Icon containers */
Animation Classes
| Class | Effect | When to Use |
|---|---|---|
animate-pulse | Opacity fade in/out (shimmer) | On every skeleton block — this is the core loading indicator |
animate-fade-in | Opacity 0→1 entrance | On skeleton containers — smooth appearance when switching views |
Sizing Convention
Icon placeholder → w-10 h-10 or w-12 h-12 rounded-xl
Title placeholder → h-4 w-24 or h-4 w-32
Subtitle → h-3 w-16 or h-3 w-20
Value/number → h-6 w-16 or h-8 w-20
Badge → h-4 w-16 rounded-full or h-6 w-20 rounded-full
Button → h-8 flex-1 rounded-lg
Avatar → w-8 h-8 rounded-full (with border-2 border-white for stacked)
Progress bar → h-2 w-full rounded-full
6. File Organization
Directory Structure
components/skeletons/
├── PageSkeletonRegistry.tsx # Tier 1 orchestrator (DO NOT modify)
└── {Module}Skeletons.tsx # Per-module skeleton components
Naming Convention
| Type | Naming Pattern | Example |
|---|---|---|
| Full-page skeleton | {PageName}Skeleton | POSSkeleton, InventorySkeleton |
| Section skeleton | {SectionName}Skeleton | ItemRowSkeleton, ProgressBarSkeleton |
| Composite skeleton | {Feature}{Type}Skeleton | KPIGridSkeleton, StatsRowSkeleton |
| Skeleton file | {Module}Skeletons.tsx | SalesSkeletons.tsx, HRSkeletons.tsx |
7. Step-by-Step: Adding Skeletons to a New Feature
Scenario A: New Full Page (Tier 1)
- Study the page layout — identify the major visual sections
- Create skeleton file in
components/skeletons/{Module}Skeleton.tsx - Mirror the layout exactly — same grid, same gaps, same dimensions
- Register in pageRegistry.ts:
'my-page': { skeleton: MyPageSkeleton, // ..
Content truncated.