gentelella
Version:
Gentelella v4 — free admin template. 60 pages, 20 chart variants, fully interactive inbox & kanban, live theme generator, component playground, PWA-ready. Vite 8, vanilla JS, no Bootstrap, no jQuery.
941 lines (880 loc) • 46.8 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playground | Gentelella 2026 v4</title>
<link rel="icon" href="../images/favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="module" src="/src/main-v4.js"></script>
</head>
<body data-shell="admin" data-page="playground" data-breadcrumb="Home > UI > Playground">
<main class="main">
<div class="page-wrapper">
<div class="page-header">
<div class="page-header-row">
<div>
<div class="page-pretitle">Developer</div>
<h1 class="page-title">Component playground</h1>
</div>
<div class="page-actions">
<a class="btn btn-outline" href="general_elements.html">All UI</a>
<a class="btn btn-outline" href="form.html">Forms</a>
<a class="btn btn-outline" href="theme.html">Theme generator</a>
</div>
</div>
</div>
<div class="banner banner-info">
<svg class="banner-icon" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 5v.01M8 7v4"/></svg>
<div class="banner-body"><strong>Edit any code block live</strong> — type into it and the preview above updates in real time. Or click <strong>Copy</strong> to grab the HTML for your own page. Every component uses the v4 design tokens, so it'll match your theme out of the box.</div>
</div>
<div class="playground-layout">
<!-- Left rail: jump nav -->
<aside class="pg-nav">
<div class="pg-nav-label">Components</div>
<a class="pg-nav-link active" href="#buttons">Buttons</a>
<a class="pg-nav-link" href="#status">Status & badges</a>
<a class="pg-nav-link" href="#alerts">Alerts</a>
<a class="pg-nav-link" href="#cards">Cards</a>
<a class="pg-nav-link" href="#forms-basic">Form inputs</a>
<a class="pg-nav-link" href="#forms-advanced">Advanced forms</a>
<a class="pg-nav-link" href="#tables">Tables</a>
<a class="pg-nav-link" href="#tabs">Tabs</a>
<a class="pg-nav-link" href="#progress">Progress</a>
<a class="pg-nav-link" href="#stats">Stat tiles</a>
<a class="pg-nav-link" href="#timeline">Timeline</a>
<a class="pg-nav-link" href="#accordion">Accordion</a>
<a class="pg-nav-link" href="#empty">Empty state</a>
<div class="pg-nav-label" style="margin-top:14px">Async patterns</div>
<a class="pg-nav-link" href="#skeleton-table">Skeleton table</a>
<a class="pg-nav-link" href="#skeleton-tiles">Skeleton tiles</a>
<a class="pg-nav-link" href="#async-list">List lifecycle</a>
<a class="pg-nav-link" href="#submit-spinner">Submit spinner</a>
<a class="pg-nav-link" href="#banners">Banners</a>
<div class="pg-nav-label" style="margin-top:14px">Markup helpers</div>
<a class="pg-nav-link" href="#helpers-intro">Overview</a>
<a class="pg-nav-link" href="#helpers-stat">statTile()</a>
<a class="pg-nav-link" href="#helpers-status">statusBadge()</a>
<a class="pg-nav-link" href="#helpers-customer">customerCell()</a>
<a class="pg-nav-link" href="#helpers-activity">activityItem()</a>
<a class="pg-nav-link" href="#helpers-visitor">visitorRow()</a>
<a class="pg-nav-link" href="#helpers-empty">emptyState()</a>
</aside>
<div class="pg-content" id="pg-content">
<!-- Buttons -->
<section class="pg-block" id="buttons">
<h2>Buttons</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview>
<button class="btn btn-primary">Primary</button>
<button class="btn btn-outline">Outline</button>
<button class="btn btn-ghost">Ghost</button>
<button class="btn btn-danger">Danger</button>
<button class="btn btn-primary btn-sm">Small</button>
<button class="btn btn-primary" disabled>Disabled</button>
</div>
</div>
</section>
<!-- Status -->
<section class="pg-block" id="status">
<h2>Status & badges</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview>
<span class="status status-green">Active</span>
<span class="status status-yellow">Pending</span>
<span class="status status-red">Failed</span>
<span class="status status-blue">Info</span>
<span class="chip">Tag</span>
<span class="chip">Removable<button class="chip-close" aria-label="Remove">×</button></span>
</div>
</div>
</section>
<!-- Alerts -->
<section class="pg-block" id="alerts">
<h2>Alerts</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview style="display:flex;flex-direction:column;gap:10px;align-items:stretch">
<div class="alert alert-success">
<svg class="alert-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5 8l2 2 4-4"/></svg>
<div class="alert-body"><strong>Saved!</strong> Changes have been published.</div>
</div>
<div class="alert alert-warning">
<svg class="alert-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2L1 14h14L8 2z"/><path d="M8 6v3M8 11v.01"/></svg>
<div class="alert-body"><strong>Heads up.</strong> API quota at 87%.</div>
</div>
<div class="alert alert-danger">
<svg class="alert-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5 5l6 6M11 5l-6 6"/></svg>
<div class="alert-body"><strong>Payment failed.</strong> Retry to continue.</div>
</div>
</div>
</div>
</section>
<!-- Cards -->
<section class="pg-block" id="cards">
<h2>Cards</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
<div class="card">
<div class="card-header"><div class="card-title">Plain card</div></div>
<div class="card-body" style="font-size:13px;color:var(--text-secondary);line-height:1.5">A simple card with header and body. The most common building block.</div>
</div>
<div class="card">
<div class="card-header">
<div>
<div class="card-title">With subtitle</div>
<div class="card-subtitle">Plus an action</div>
</div>
<button class="btn btn-outline btn-sm">Action</button>
</div>
<div class="card-body" style="font-size:13px;color:var(--text-secondary);line-height:1.5">Header has room for actions on the right.</div>
</div>
</div>
</div>
</section>
<!-- Form inputs -->
<section class="pg-block" id="forms-basic">
<h2>Form inputs</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px">
<div class="form-group">
<label class="form-label" for="pg-text">Text</label>
<input id="pg-text" class="form-control" type="text" placeholder="Type here…">
</div>
<div class="form-group">
<label class="form-label" for="pg-select">Select</label>
<select id="pg-select" class="form-control">
<option>Option A</option>
<option>Option B</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Switch</label>
<label class="switch"><input type="checkbox" checked><span class="track"></span><span class="switch-label">Enabled</span></label>
</div>
<div class="form-group">
<label class="form-label">Segmented</label>
<div class="segmented">
<label><input type="radio" name="pg-seg" checked><span>Day</span></label>
<label><input type="radio" name="pg-seg"><span>Week</span></label>
<label><input type="radio" name="pg-seg"><span>Month</span></label>
</div>
</div>
</div>
</div>
</section>
<!-- Advanced forms -->
<section class="pg-block" id="forms-advanced">
<h2>Advanced form controls</h2>
<div class="pg-card" data-source data-source-label="Date-range picker">
<div class="pg-preview" data-preview style="max-width:340px">
<div class="date-range" data-date-range>
<input class="form-control" placeholder="Pick a date range" aria-label="Date range">
</div>
</div>
</div>
<div class="pg-card" data-source data-source-label="Multi-select">
<div class="pg-preview" data-preview style="max-width:480px">
<div class="multi-select" data-multi-select data-options="Design,Engineering,Product,Marketing,Support,Sales,Operations,Legal,Finance,HR">
<select multiple hidden>
<option value="Design" selected>Design</option>
<option value="Engineering" selected>Engineering</option>
<option value="Product">Product</option>
<option value="Marketing">Marketing</option>
<option value="Support">Support</option>
<option value="Sales">Sales</option>
<option value="Operations">Operations</option>
<option value="Legal">Legal</option>
<option value="Finance">Finance</option>
<option value="HR">HR</option>
</select>
</div>
</div>
</div>
<div class="pg-card" data-source data-source-label="Rich text editor">
<div class="pg-preview" data-preview>
<div class="rich-text" data-rich-text>
<textarea hidden><p>Click any button in the toolbar — <strong>bold</strong>, <em>italic</em>, lists, links, code blocks. Try <kbd>⌘B</kbd> / <kbd>⌘I</kbd> / <kbd>⌘K</kbd>.</p></textarea>
</div>
</div>
</div>
</section>
<!-- Tables -->
<section class="pg-block" id="tables">
<h2>Tables</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview>
<table class="table">
<thead><tr><th>Name</th><th>Plan</th><th>Status</th><th style="text-align:right">MRR</th></tr></thead>
<tbody>
<tr><td><div class="cell-customer"><div class="cell-avatar" style="background:var(--primary)">SK</div><span class="cell-strong">Sarah K.</span></div></td><td><span class="chip">Pro</span></td><td><span class="status status-green">Active</span></td><td class="cell-strong" style="text-align:right">$199</td></tr>
<tr><td><div class="cell-customer"><div class="cell-avatar" style="background:var(--azure)">MR</div><span class="cell-strong">Michael R.</span></div></td><td><span class="chip">Business</span></td><td><span class="status status-green">Active</span></td><td class="cell-strong" style="text-align:right">$499</td></tr>
<tr><td><div class="cell-customer"><div class="cell-avatar" style="background:var(--yellow)">EW</div><span class="cell-strong">Emily W.</span></div></td><td><span class="chip">Starter</span></td><td><span class="status status-yellow">Pending</span></td><td class="cell-strong" style="text-align:right">$49</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Tabs -->
<section class="pg-block" id="tabs">
<h2>Tabs</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview>
<div class="chart-tabs">
<button class="chart-tab active">Overview</button>
<button class="chart-tab">Activity</button>
<button class="chart-tab">Settings</button>
</div>
</div>
</div>
</section>
<!-- Progress -->
<section class="pg-block" id="progress">
<h2>Progress</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview style="display:flex;flex-direction:column;gap:14px;align-items:stretch">
<div>
<div style="display:flex;justify-content:space-between;font-size:12.5px;margin-bottom:4px"><span>Storage</span><span style="color:var(--text-muted)">62 / 100 GB</span></div>
<div class="progress-thin"><div class="bar" style="width:62%;background:var(--primary)"></div></div>
</div>
<div>
<div style="display:flex;justify-content:space-between;font-size:12.5px;margin-bottom:4px"><span>API quota</span><span style="color:var(--text-muted)">87,400 / 100,000</span></div>
<div class="progress-thin"><div class="bar" style="width:87%;background:var(--yellow)"></div></div>
</div>
<div class="loading-bar" style="height:3px;background:var(--bg-surface-secondary);border-radius:3px;overflow:hidden"></div>
</div>
</div>
</section>
<!-- Stat tiles -->
<section class="pg-block" id="stats">
<h2>Stat tiles</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
<div class="card"><div class="stat">
<div class="stat-icon teal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 1v22M5 8l7-7 7 7"/></svg></div>
<div class="stat-content">
<div class="stat-label">Active users</div>
<div class="stat-value-row"><span class="stat-value">8,432</span><span class="stat-change up">↑ 14%</span></div>
<div class="stat-subtext">vs last week</div>
</div>
</div></div>
<div class="card"><div class="stat">
<div class="stat-icon red"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18"/><path d="M19 9l-5 5-4-4-3 3"/></svg></div>
<div class="stat-content">
<div class="stat-label">Churn</div>
<div class="stat-value-row"><span class="stat-value">2.4%</span><span class="stat-change down">↓ 0.4pp</span></div>
<div class="stat-subtext">improving</div>
</div>
</div></div>
</div>
</div>
</section>
<!-- Timeline -->
<section class="pg-block" id="timeline">
<h2>Timeline</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview>
<div class="timeline">
<div class="timeline-item is-primary">
<div class="ti-time">Just now</div>
<div class="ti-title">Deployment succeeded</div>
<div class="ti-desc">v4.0.2 in production · 28s</div>
</div>
<div class="timeline-item is-blue">
<div class="ti-time">14 min ago</div>
<div class="ti-title">PR #248 merged</div>
</div>
<div class="timeline-item is-yellow">
<div class="ti-time">3 hours ago</div>
<div class="ti-title">Review requested</div>
</div>
</div>
</div>
</div>
</section>
<!-- Accordion -->
<section class="pg-block" id="accordion">
<h2>Accordion</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview>
<div class="accordion">
<details class="accordion-item" open>
<summary class="accordion-summary">First panel
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
</summary>
<div class="accordion-content">Content for the first panel goes here.</div>
</details>
<details class="accordion-item">
<summary class="accordion-summary">Second panel
<svg class="chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 6l4 4 4-4"/></svg>
</summary>
<div class="accordion-content">Click the chevron to expand.</div>
</details>
</div>
</div>
</div>
</section>
<!-- Empty state -->
<section class="pg-block" id="empty">
<h2>Empty state</h2>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview>
<div class="empty-state">
<div class="empty-state-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 8l9 6 9-6"/></svg>
</div>
<div class="empty-state-title">No messages yet</div>
<div class="empty-state-text">When you receive messages, they'll show up here.</div>
<div class="empty-state-actions">
<button class="btn btn-primary btn-sm">Compose</button>
</div>
</div>
</div>
</div>
</section>
<!-- ── Async patterns ──────────────────────────────────────────── -->
<section class="pg-block" id="async-intro">
<div class="banner banner-info" style="margin-bottom:14px">
<svg class="banner-icon" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 5v.01M8 7v4"/></svg>
<div class="banner-body"><strong>Async patterns.</strong> The four states every list/grid passes through: <em>loading → loaded</em>, or <em>loading → empty</em>, or <em>loading → error</em>. Wire all four and your app feels solid even on flaky networks. The data-adapter pattern in <code>src/v4/data-adapter.js</code> hands you the data; the components below are what you render in each state.</div>
</div>
</section>
<section class="pg-block" id="skeleton-table">
<h2>Skeleton table</h2>
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 12px;line-height:1.55">Render placeholder rows while data loads. Width variation across columns prevents the "jelly bean" look. Fades in via the <code>.skeleton</code> shimmer animation defined in <code>_components.scss</code>.</p>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview>
<table class="table" style="width:100%">
<thead><tr><th>Customer</th><th>Plan</th><th>Status</th><th style="text-align:right">MRR</th></tr></thead>
<tbody>
<tr><td><span class="skeleton skeleton-text" style="width:65%"></span></td><td><span class="skeleton skeleton-text" style="width:50%"></span></td><td><span class="skeleton skeleton-text" style="width:40%"></span></td><td><span class="skeleton skeleton-text" style="width:30%;display:inline-block"></span></td></tr>
<tr><td><span class="skeleton skeleton-text" style="width:80%"></span></td><td><span class="skeleton skeleton-text" style="width:45%"></span></td><td><span class="skeleton skeleton-text" style="width:55%"></span></td><td><span class="skeleton skeleton-text" style="width:35%;display:inline-block"></span></td></tr>
<tr><td><span class="skeleton skeleton-text" style="width:55%"></span></td><td><span class="skeleton skeleton-text" style="width:60%"></span></td><td><span class="skeleton skeleton-text" style="width:50%"></span></td><td><span class="skeleton skeleton-text" style="width:40%;display:inline-block"></span></td></tr>
<tr><td><span class="skeleton skeleton-text" style="width:70%"></span></td><td><span class="skeleton skeleton-text" style="width:55%"></span></td><td><span class="skeleton skeleton-text" style="width:42%"></span></td><td><span class="skeleton skeleton-text" style="width:32%;display:inline-block"></span></td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="pg-block" id="skeleton-tiles">
<h2>Skeleton tiles</h2>
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 12px;line-height:1.55">Use for card grids — dashboards, file managers, product galleries. Avatar circle + headline + 2 lines is the canonical shape.</p>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
<div class="card" style="padding:16px">
<span class="skeleton skeleton-circle" style="width:32px;height:32px;margin-bottom:10px"></span>
<span class="skeleton skeleton-text skeleton-text-lg" style="width:60%"></span>
<span class="skeleton skeleton-text" style="width:90%"></span>
<span class="skeleton skeleton-text" style="width:75%;margin-bottom:0"></span>
</div>
<div class="card" style="padding:16px">
<span class="skeleton skeleton-circle" style="width:32px;height:32px;margin-bottom:10px"></span>
<span class="skeleton skeleton-text skeleton-text-lg" style="width:50%"></span>
<span class="skeleton skeleton-text" style="width:85%"></span>
<span class="skeleton skeleton-text" style="width:65%;margin-bottom:0"></span>
</div>
<div class="card" style="padding:16px">
<span class="skeleton skeleton-circle" style="width:32px;height:32px;margin-bottom:10px"></span>
<span class="skeleton skeleton-text skeleton-text-lg" style="width:70%"></span>
<span class="skeleton skeleton-text" style="width:80%"></span>
<span class="skeleton skeleton-text" style="width:55%;margin-bottom:0"></span>
</div>
</div>
</div>
</section>
<section class="pg-block" id="async-list">
<h2>List lifecycle — interactive</h2>
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 12px;line-height:1.55">Click a button below to simulate each state. This is exactly what <a href="orders.html?api=1">orders.html</a> and the inbox use under the hood.</p>
<div class="pg-card">
<div class="pg-preview" data-preview style="flex-direction:column;align-items:stretch;gap:0;padding:0">
<div style="display:flex;gap:6px;padding:14px 16px;border-bottom:1px solid var(--border-color-light);flex-wrap:wrap">
<button class="btn btn-outline btn-sm" data-lifecycle="success">Loading → success</button>
<button class="btn btn-outline btn-sm" data-lifecycle="empty">Loading → empty</button>
<button class="btn btn-outline btn-sm" data-lifecycle="error">Loading → error</button>
<button class="btn btn-ghost btn-sm" data-lifecycle="reset">Reset</button>
</div>
<div id="async-list-host" style="padding:14px 16px;min-height:200px"></div>
</div>
</div>
</section>
<section class="pg-block" id="submit-spinner">
<h2>Submit spinner</h2>
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 12px;line-height:1.55">Disable the button + swap label for a spinner during the request. Re-enable + restore label when it resolves. Click below to simulate a 1.5s submit.</p>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview>
<form id="submit-spinner-form" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap" onsubmit="return false">
<input class="form-control" placeholder="Type something" style="width:260px" aria-label="Sample input" required>
<button type="submit" class="btn btn-primary" data-pg-submit>
<span class="btn-spinner spinner spinner-sm" hidden style="border-top-color:#fff;margin-right:6px"></span>
<span data-pg-submit-label>Save</span>
</button>
<span style="font-size:12px;color:var(--text-muted)">↑ Click "Save"</span>
</form>
</div>
</div>
</section>
<section class="pg-block" id="banners">
<h2>Banners — feedback at the page level</h2>
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 12px;line-height:1.55">For things the user needs to see and may want to act on (failed payment, quota near limit). For ephemeral feedback (saved, copied, deleted) use a toast instead — see <a href="general_elements.html#toasts">general elements</a>.</p>
<div class="pg-card" data-source>
<div class="pg-preview" data-preview style="flex-direction:column;align-items:stretch;gap:10px">
<div class="banner">
<svg class="banner-icon" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5 8l2 2 4-4"/></svg>
<div class="banner-body"><strong>Saved.</strong> Changes are live.</div>
</div>
<div class="banner banner-info">
<svg class="banner-icon" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 5v.01M8 7v4"/></svg>
<div class="banner-body"><strong>Heads up.</strong> Maintenance window scheduled for Friday 02:00 UTC.</div>
</div>
<div class="banner banner-warning">
<svg class="banner-icon" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1l7 13H1L8 1z"/><path d="M8 6v4"/></svg>
<div class="banner-body"><strong>API quota at 87%.</strong> You'll hit the cap by Friday.</div>
<div class="banner-actions"><button class="btn btn-outline btn-sm">Upgrade plan</button></div>
</div>
<div class="banner banner-danger">
<svg class="banner-icon" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5 5l6 6M11 5l-6 6"/></svg>
<div class="banner-body"><strong>Payment failed.</strong> Update your card to keep your subscription active.</div>
<div class="banner-actions"><button class="btn btn-outline btn-sm">Update card</button></div>
</div>
</div>
</div>
</section>
<section class="pg-block" id="helpers-intro">
<h2>Markup helpers — stop hand-writing scaffolds</h2>
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 8px;line-height:1.55">For pages that build content from data (orders, inbox threads, kanban cards…) the boilerplate adds up. <code>src/v4/markup.js</code> exposes pure functions that return HTML strings — no framework, no virtual DOM. Auto-escapes user content. Drop the result into <code>innerHTML</code> or interpolate into a template literal.</p>
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 12px;line-height:1.55">Each helper below shows the call site (top) and the rendered output (bottom).</p>
</section>
<section class="pg-block" id="helpers-stat">
<h3 style="margin:0 0 10px;font-size:15px">statTile()</h3>
<pre class="pg-code" style="margin-bottom:12px"><code>import { statTile } from '/src/v4/markup.js';
document.querySelector('#stats').innerHTML = [
statTile({ label: 'Revenue', value: '$84,520', color: 'green',
change: { pct: '+18%', direction: 'up' }, subtext: '$3,218 today' }),
statTile({ label: 'Pending', value: '42', color: 'yellow',
change: { pct: '-3%', direction: 'down' }, subtext: '5 awaiting payment' })
].join('');</code></pre>
<div id="helpers-stat-host" class="row col-2" style="gap:12px"></div>
</section>
<section class="pg-block" id="helpers-status">
<h3 style="margin:0 0 10px;font-size:15px">statusBadge()</h3>
<pre class="pg-code" style="margin-bottom:12px"><code>statusBadge('Paid', 'green')
statusBadge('Pending', 'yellow')
statusBadge('Failed', 'red')</code></pre>
<div id="helpers-status-host" style="display:flex;gap:8px;flex-wrap:wrap"></div>
</section>
<section class="pg-block" id="helpers-customer">
<h3 style="margin:0 0 10px;font-size:15px">customerCell() — table cell with avatar + name</h3>
<pre class="pg-code" style="margin-bottom:12px"><code>tbody.innerHTML = orders.map((o) =>
`<tr><td>${customerCell({ name: o.name, avatarColor: o.color })}</td></tr>`
).join('');</code></pre>
<table class="table" style="max-width:340px"><tbody id="helpers-customer-host"></tbody></table>
</section>
<section class="pg-block" id="helpers-activity">
<h3 style="margin:0 0 10px;font-size:15px">activityItem() — feeds, audit logs</h3>
<pre class="pg-code" style="margin-bottom:12px"><code>const html = items.map((i) => activityItem({
initials: i.initials, avatarBg: i.bg,
bodyHtml: `<strong>${escapeHtml(i.user)}</strong> ${escapeHtml(i.text)}`,
time: i.time
})).join('');
container.innerHTML = `<ul class="activity-list">${html}</ul>`;</code></pre>
<ul class="activity-list" id="helpers-activity-host" style="max-width:420px"></ul>
</section>
<section class="pg-block" id="helpers-visitor">
<h3 style="margin:0 0 10px;font-size:15px">visitorRow() — distribution bars</h3>
<pre class="pg-code" style="margin-bottom:12px"><code>const html = data.map((d) =>
visitorRow({ name: d.country, pct: d.pct, flag: d.flag })
).join('');</code></pre>
<div id="helpers-visitor-host" style="max-width:420px"></div>
</section>
<section class="pg-block" id="helpers-empty">
<h3 style="margin:0 0 10px;font-size:15px">emptyState() — fallback for no results</h3>
<pre class="pg-code" style="margin-bottom:12px"><code>container.innerHTML = emptyState({
title: 'No orders yet',
desc: "Orders will appear here once your first customer checks out.",
iconHtml: '<svg ...></svg>',
actionHtml: '<button class="btn btn-primary">Create test order</button>'
});</code></pre>
<div class="card"><div class="card-body" id="helpers-empty-host"></div></div>
</section>
</div>
</div>
</div>
</main>
<script type="module">
import { showToast } from '/src/v4/toast.js';
import { statTile, statusBadge, customerCell, activityItem, visitorRow, emptyState, escapeHtml } from '/src/v4/markup.js';
// Build a code-source row under each [data-source] card.
function indent(html) {
// Lightly format the HTML for display: collapse whitespace + indent.
// (Production HTML is already nicely indented in the source — but we
// capture from the live DOM, so re-format for readability.)
return html
.replace(/></g, '>\n<')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join('\n');
}
document.querySelectorAll('[data-source]').forEach((card) => {
const preview = card.querySelector('[data-preview]');
if (!preview) return;
const originalHtml = preview.innerHTML.trim();
const label = card.dataset.sourceLabel || '';
const code = indent(originalHtml);
const block = document.createElement('div');
block.className = 'pg-source';
block.innerHTML = `
<div class="pg-source-head">
<span class="pg-source-label">${label || 'HTML'} <span class="pg-edit-hint">— edit to preview</span></span>
<div style="display:flex;gap:6px">
<button type="button" class="btn btn-outline btn-sm pg-reset" hidden>↺ Reset</button>
<button type="button" class="btn btn-outline btn-sm pg-copy" aria-label="Copy code">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="6" y="6" width="8" height="8" rx="1.5"/><path d="M3 10V3h7"/></svg>
Copy
</button>
</div>
</div>
<pre class="pg-code pg-code-edit" contenteditable="plaintext-only" spellcheck="false" aria-label="HTML source — edit to update preview"></pre>
`;
const codeEl = block.querySelector('.pg-code');
const resetBtn = block.querySelector('.pg-reset');
codeEl.textContent = code;
card.appendChild(block);
// Live edit: debounce 250ms, set preview.innerHTML from textContent.
// Browser auto-corrects malformed HTML — we don't try to validate.
let editTimer;
codeEl.addEventListener('input', () => {
clearTimeout(editTimer);
editTimer = setTimeout(() => {
preview.innerHTML = codeEl.textContent;
resetBtn.hidden = false;
}, 250);
});
resetBtn.addEventListener('click', () => {
preview.innerHTML = originalHtml;
codeEl.textContent = indent(originalHtml);
resetBtn.hidden = true;
});
block.querySelector('.pg-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(codeEl.textContent);
showToast('Copied to clipboard', { variant: 'success' });
} catch (_e) {
showToast('Copy failed — select and Ctrl+C', { variant: 'error' });
}
});
});
// Scrollspy for the left rail.
const links = [...document.querySelectorAll('.pg-nav-link')];
const sections = [...document.querySelectorAll('.pg-block')];
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id;
links.forEach((l) => l.classList.toggle('active', l.getAttribute('href') === '#' + id));
}
});
}, { rootMargin: '-30% 0px -60% 0px', threshold: 0 });
sections.forEach((s) => observer.observe(s));
// Smooth scroll
links.forEach((a) => a.addEventListener('click', (e) => {
e.preventDefault();
const t = document.querySelector(a.getAttribute('href'));
if (t) t.scrollIntoView({ behavior: 'smooth', block: 'start' });
}));
// ── Async list lifecycle simulator ───────────────────────────────────
// Demonstrates the four states a real list goes through: loading → loaded /
// empty / error. Buttons let viewers trigger each transition.
const lifecycleHost = document.getElementById('async-list-host');
if (lifecycleHost) {
const SAMPLE = [
{ name: 'Sarah K.', plan: 'Pro', status: 'Active', mrr: '$199' },
{ name: 'Michael R.', plan: 'Business', status: 'Active', mrr: '$499' },
{ name: 'Emily W.', plan: 'Starter', status: 'Pending', mrr: '$49' },
{ name: 'Diego R.', plan: 'Pro', status: 'Active', mrr: '$199' }
];
const renderIdle = () => {
lifecycleHost.innerHTML = `<div style="text-align:center;color:var(--text-muted);font-size:13px;padding:60px 0">Click a button above to simulate a state.</div>`;
};
const renderLoading = () => {
const row = `<tr>${'<td><span class="skeleton skeleton-text" style="width:70%"></span></td>'.repeat(4)}</tr>`;
lifecycleHost.innerHTML = `
<table class="table" style="width:100%">
<thead><tr><th>Customer</th><th>Plan</th><th>Status</th><th style="text-align:right">MRR</th></tr></thead>
<tbody>${row.repeat(4)}</tbody>
</table>`;
};
const renderLoaded = () => {
lifecycleHost.innerHTML = `
<table class="table" style="width:100%">
<thead><tr><th>Customer</th><th>Plan</th><th>Status</th><th style="text-align:right">MRR</th></tr></thead>
<tbody>${SAMPLE.map((r) => `
<tr>
<td class="cell-strong">${r.name}</td>
<td><span class="chip">${r.plan}</span></td>
<td><span class="status status-${r.status === 'Active' ? 'green' : 'yellow'}">${r.status}</span></td>
<td class="cell-strong" style="text-align:right">${r.mrr}</td>
</tr>`).join('')}
</tbody>
</table>`;
};
const renderEmpty = () => {
lifecycleHost.innerHTML = `
<div class="empty-state" style="padding:32px 24px">
<div class="empty-state-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="7"/><path d="M16 16l4 4"/></svg>
</div>
<div class="empty-state-title">No customers found</div>
<div class="empty-state-text">Try changing your filters, or create your first customer to get started.</div>
<div class="empty-state-actions">
<button class="btn btn-primary btn-sm">+ New customer</button>
</div>
</div>`;
};
const renderError = (retry) => {
lifecycleHost.innerHTML = `
<div class="banner banner-danger">
<svg class="banner-icon" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M5 5l6 6M11 5l-6 6"/></svg>
<div class="banner-body"><strong>Couldn't load customers.</strong> Check your network connection and try again.</div>
<div class="banner-actions"><button class="btn btn-outline btn-sm" id="lifecycle-retry">Retry</button></div>
</div>`;
document.getElementById('lifecycle-retry')?.addEventListener('click', retry);
};
renderIdle();
document.querySelectorAll('[data-lifecycle]').forEach((btn) => {
btn.addEventListener('click', () => {
const state = btn.dataset.lifecycle;
if (state === 'reset') { renderIdle(); return; }
renderLoading();
// Real fetch latency simulation. Devs reading this should note the
// pattern: render skeleton synchronously, then resolve the data, then
// render the final state. Never go from idle straight to loaded.
setTimeout(() => {
if (state === 'success') renderLoaded();
else if (state === 'empty') renderEmpty();
else if (state === 'error') {
renderError(() => {
renderLoading();
setTimeout(renderLoaded, 700); // retry "succeeds"
});
}
}, 700);
});
});
}
// ── Submit-spinner pattern ───────────────────────────────────────────
// Disable the button and swap the label for a spinner during the request,
// then restore on resolve. Keeps the user from double-submitting and gives
// clear "something is happening" feedback.
const submitForm = document.getElementById('submit-spinner-form');
if (submitForm) {
submitForm.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = submitForm.querySelector('[data-pg-submit]');
const spinner = btn.querySelector('.btn-spinner');
const label = btn.querySelector('[data-pg-submit-label]');
const original = label.textContent;
btn.disabled = true;
spinner.hidden = false;
label.textContent = 'Saving…';
try {
// Simulate a 1.5s API call. Replace with await fetch(...)
await new Promise((r) => setTimeout(r, 1500));
showToast('Saved ✓', { variant: 'success' });
submitForm.reset();
} catch (err) {
showToast('Failed to save', { variant: 'error' });
} finally {
btn.disabled = false;
spinner.hidden = true;
label.textContent = original;
}
});
}
// ── Markup helpers — populate the live previews ──────────────────────
const ICON_REVENUE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>';
const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
const statHost = document.getElementById('helpers-stat-host');
if (statHost) {
statHost.innerHTML = [
statTile({ label: 'Revenue', value: '$84,520', color: 'green', iconHtml: ICON_REVENUE,
change: { pct: '+18%', direction: 'up' }, subtext: '$3,218 today' }),
statTile({ label: 'Pending', value: '42', color: 'yellow', iconHtml: ICON_CLOCK,
change: { pct: '-3%', direction: 'down' }, subtext: '5 awaiting payment' })
].join('');
}
const statusHost = document.getElementById('helpers-status-host');
if (statusHost) {
statusHost.innerHTML = [
statusBadge('Paid', 'green'),
statusBadge('Processing', 'blue'),
statusBadge('Pending', 'yellow'),
statusBadge('Failed', 'red'),
statusBadge('Archived', 'gray')
].join('');
}
const customerHost = document.getElementById('helpers-customer-host');
if (customerHost) {
const orders = [
{ name: 'Sarah Klein', color: 'var(--primary)' },
{ name: 'Michael Reese', color: 'var(--blue)' },
{ name: 'Emily Wang', color: 'var(--yellow)' }
];
customerHost.innerHTML = orders.map((o) =>
`<tr><td>${customerCell({ name: o.name, avatarColor: o.color })}</td></tr>`
).join('');
}
const activityHost = document.getElementById('helpers-activity-host');
if (activityHost) {
const items = [
{ user: 'Sarah K.', text: 'placed a new order for $245.00', initials: 'SK', bg: 'linear-gradient(135deg,var(--primary),var(--primary-dk))', time: '2 min ago' },
{ user: 'Michael R.', text: 'registered a new account', initials: 'MR', bg: 'linear-gradient(135deg,var(--blue),#0550a0)', time: '18 min ago' },
{ user: 'Payment', text: 'processed — Invoice #4521', initials: 'PA', bg: 'linear-gradient(135deg,var(--green),#1a8a32)', time: '45 min ago' }
];
activityHost.innerHTML = items.map((i) => activityItem({
initials: i.initials, avatarBg: i.bg,
bodyHtml: `<strong>${escapeHtml(i.user)}</strong> ${escapeHtml(i.text)}`,
time: i.time
})).join('');
}
const visitorHost = document.getElementById('helpers-visitor-host');
if (visitorHost) {
const data = [
{ flag: '🇺🇸', country: 'United States', pct: 33 },
{ flag: '🇫🇷', country: 'France', pct: 27 },
{ flag: '🇩🇪', country: 'Germany', pct: 16 },
{ flag: '🇪🇸', country: 'Spain', pct: 11 }
];
visitorHost.innerHTML = data.map((d) =>
visitorRow({ name: d.country, pct: d.pct, flag: d.flag })
).join('');
}
const emptyHost = document.getElementById('helpers-empty-host');
if (emptyHost) {
emptyHost.innerHTML = emptyState({
title: 'No orders yet',
desc: "Orders will appear here once your first customer checks out.",
iconHtml: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>',
actionHtml: '<button class="btn btn-primary btn-sm">Create test order</button>'
});
}
</script>
<style>
.playground-layout {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
gap: 20px;
align-items: start;
margin-top: 16px;
}
.pg-nav {
position: sticky;
top: 76px;
display: flex;
flex-direction: column;
gap: 1px;
}
.pg-nav-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
padding: 4px 12px 8px;
}
.pg-nav-link {
display: block;
padding: 6px 12px;
font-size: 12.5px;
color: var(--text-secondary);
border-radius: var(--radius-sm);
text-decoration: none;
transition: background 100ms, color 100ms;
}
.pg-nav-link:hover { background: var(--bg-surface-secondary); color: var(--text); text-decoration: none; }
.pg-nav-link.active {
background: var(--primary-lt);
color: var(--primary);
font-weight: var(--font-weight-medium);
}
.pg-content { display: flex; flex-direction: column; gap: 28px; }
.pg-block h2 {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin: 0 0 12px;
letter-spacing: -0.2px;
}
.pg-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.pg-preview {
padding: 24px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
background: var(--bg-surface-secondary);
}
.pg-source {
border-top: 1px solid var(--border-color-light);
background: var(--bg-surface);
}
.pg-source-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-bottom: 1px solid var(--border-color-light);
}
.pg-source-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.pg-code {
margin: 0;
padding: 14px 16px;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.55;
color: var(--text);
background: var(--bg-surface);
overflow-x: auto;
max-height: 320px;
overflow-y: auto;
white-space: pre;
}
.pg-code-edit {
cursor: text;
outline: none;
transition: background 120ms;
}
.pg-code-edit:hover { background: var(--bg-surface-secondary); }
.pg-code-edit:focus {
background: var(--bg-surface-secondary);
box-shadow: inset 3px 0 0 var(--primary);
}
.pg-edit-hint {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: var(--text-disabled);
margin-left: 4px;
}
.pg-card + .pg-card { margin-top: 14px; }
@media (max-width: 900px) {
.playground-layout { grid-template-columns: 1fr; }
.pg-nav { position: static; flex-direction: row; flex-wrap: wrap; }
.pg-nav-label { width: 100%; padding: 0 0 4px; }
.pg-nav-link { padding: 5px 10px; font-size: 12px; }
}
</style>
</body>
</html>