Component Playground
Gentelella ships a component playground at production/playground.html — every reusable component side-by-side with its exact HTML. Live-editable code blocks, copy buttons, scrollspy navigation. Stop guessing the markup.
Last updated May 22, 2026
The playground is Gentelella’s “single source of truth” for component markup. Every reusable component renders next to its exact HTML in a contenteditable code block. You can:
- Read the markup to copy by hand
- Edit the code live and see the preview update in real time
- Copy the snippet straight to your clipboard with one click
- Reset any block to the original after experimenting
How it’s wired
Each component example lives inside a <div class="pg-block" data-source> card with two siblings:
- A
[data-preview]div containing the actual rendered HTML - A label (
data-source-label) for the snippet header
The page’s inline script discovers every [data-source] card on load and appends a code panel below each preview:
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);
// Build the source panel (label, Reset/Copy buttons, editable <pre>)
const block = document.createElement('div');
block.className = 'pg-source';
// ... see the source for the full markup
block.querySelector('.pg-code').textContent = code;
card.appendChild(block);
// Live edit, debounced 250ms
block.querySelector('.pg-code').addEventListener('input', (e) => {
clearTimeout(editTimer);
editTimer = setTimeout(() => {
preview.innerHTML = e.target.textContent;
}, 250);
});
// Copy to clipboard
block.querySelector('.pg-copy').addEventListener('click', async () => {
await navigator.clipboard.writeText(code);
showToast('Copied to clipboard', { variant: 'success' });
});
});
The mechanism is intentionally simple — no diffing, no validation. The browser parses whatever HTML you type, recoverable errors get cleaned up by the parser, broken markup just doesn’t render. The reset button restores the original snapshot.
What’s in the playground
The page is organized into a dozen sections — each pulled from the _components.scss and _widgets.scss partials:
- Buttons — primary, outline, ghost, sizes, icon-only, button groups
- Status — status badges (active, pending, draft, error, info)
- Alerts — info, success, warning, danger, dismissible
- Cards — basic, with header/footer, with actions menu
- Tables — basic + striped, status cells, customer cells with avatar
- Tabs — horizontal tab bar with active state
- Progress — bars, labelled, multi-color
- Stats — KPI stat tiles (the
statTile()helper frommarkup.js) - Timeline — vertical event timeline
- Accordion — expandable rows
- Empty states — illustrations + CTA for empty lists
- Banners — full-width announcement banners (info/success/warning/danger)
Each section has its own id, so deep links like #buttons or #accordion work straight from the URL.
A left-rail navigation uses IntersectionObserver scrollspy to highlight whichever section is currently in view:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const id = entry.target.id;
links.forEach((link) => {
link.classList.toggle('active', link.getAttribute('href') === `#${id}`);
});
}
});
}, { rootMargin: '-40% 0px -40% 0px' });
sections.forEach((s) => observer.observe(s));
Adding your own component
The pattern is straightforward — wrap an existing card with data-source and data-source-label:
<div class="pg-block" id="my-component" data-source data-source-label="My Component">
<h2 class="pg-block-title">My Component</h2>
<p class="pg-block-description">One-sentence description.</p>
<div class="pg-preview" data-preview>
<!-- Your component's exact markup goes here -->
<div class="my-component">
<span class="my-component-label">Label</span>
<button class="my-component-action">Action</button>
</div>
</div>
</div>
The page script will:
- Find the
[data-source]card on load - Capture
[data-preview]’s inner HTML as the snippet - Build the editable code panel below the preview
- Wire up live edit, reset, and copy
Add a matching nav-rail link if you want scrollspy to track it:
<a href="#my-component" class="pg-nav-link">My Component</a>
Markup helpers — for JS-rendered content
Some preview cards build their content dynamically using helpers from src/v4/markup.js:
import {
pageHeader, statTile, statusBadge, customerCell,
activityItem, visitorRow, emptyState, banner, skeletonRows, escapeHtml
} from '/src/v4/markup.js';
document.querySelector('[data-preview-stats]').innerHTML = `
${statTile({ label: 'Revenue', value: '$48,205', color: 'teal', change: '+12%' })}
${statTile({ label: 'New customers', value: '1,284', color: 'green', change: '+8%' })}
`;
Ten exports in total:
pageHeader({ title, pretitle, actionsHtml })— the.page-headerblock used at the top of 46 pagesstatTile({ label, value, color, iconHtml, subtext, change })— KPI cardsstatusBadge(label, color)— status pillcustomerCell({ name, initials, avatarColor })— table cell with avatar + nameactivityItem({ bodyHtml, time, initials, avatarBg })— feed rowvisitorRow({ name, pct, flag })— country/visitor leaderboard rowemptyState({ title, desc, iconHtml, actionHtml })— empty-list placeholderbanner({ body, variant, title, iconHtml, actionsHtml })— full-width bannerskeletonRows(cols, rows)— DataTables/list loading skeletonsescapeHtml(value)— safe string escape, used internally by all of the above
All return HTML strings, all auto-escape user input via escapeHtml(), all accept html-trusted fields where you need to pass raw SVG or trusted markup. Use them when you’re stamping out repeating content (rows from a fetched list, cards from an array) — they’re a duplication killer, not a templating engine.
The playground demonstrates each helper in its dedicated card with the same Copy/Reset workflow.
A note on copy-paste fidelity
The Copy button captures whatever’s currently in the editable <pre> element. If you’ve edited the code, you copy your edits — not the original. The “Reset” button is the escape hatch.
The indent() helper that formats the initial snippet does a light reformat: collapses whitespace, splits on >< boundaries, and joins back with newlines. It’s not a full HTML pretty-printer — complex inline content (like a <button> with <svg> inside it) keeps its original layout. The goal is “good enough to read”, not pixel-perfect formatting.
If you copy a snippet and the indentation looks off in your IDE, run it through your editor’s auto-format (Prettier, IDE-native HTML formatter) — that’s where opinionated formatting belongs.
Why not Storybook?
A few honest reasons:
- Storybook is a build system, not a doc. Adding it means a parallel build, a separate dev server, and a separate deploy. The playground is a page in the template — same Vite build, same bundle, same shell.
- The playground stays in sync by construction. When a component’s markup changes in
_components.scss, the preview re-renders with the new styling — no story file to update. - Live edit is rare in Storybook. Most stories are read-only. The playground’s
contenteditable <pre>lets you A/B variations in seconds without rebuilding.
If you want Storybook on top of Gentelella, nothing in the template prevents it — but the playground covers ~90% of the “what does this component look like, and what’s its markup” workflow with zero extra infrastructure.
See also
- Theming — the tokens every playground component reads from
- Theme generator — the other on-page design tool
- Architecture — how page-specific scripts like the playground’s inline module fit into the template