Neu in 1.96 Timeline, Portrait video frame
.gk-timeline.gk-video-frameGeändert in 1.95 Message
.gk-message-sm| Unit | Net | VAT | Status | Aktionen | ||
|---|---|---|---|---|---|---|
| ART-001 | Webdesign Paket S | psch | 1.200,00 € | 20 % | active | |
| ART-002 | Hosting Standard | Stk | 9,90 € | 20 % | active | |
| ART-003 | SEO Beratung | h | 95,00 € | 20 % | inactive | |
| ART-004 | Logo Design | psch | 450,00 € | 20 % | draft | |
| ART-005 | Newsletter Setup | psch | 350,00 € | 20 % | active |
$table = new Table('articles');
$table->setData($articles)
->search(['article_number', 'name'])
->column('article_number', 'Article No.', ['width' => '120px', 'sortable' => true, 'nowrap' => true, 'muted' => true])
->column('name', 'Description', ['sortable' => true, 'nowrap' => true])
->column('unit', 'Unit', ['width' => '80px', 'nowrap' => true])
->column('net_price', 'Net', ['format' => 'currency', 'align' => 'right', 'width' => '100px', 'nowrap' => true])
->column('tax_rate', 'VAT', ['format' => 'percent', 'width' => '80px', 'nowrap' => true])
->column('is_active', 'Status', ['format' => 'label', 'nowrap' => true])
->filter('is_active', 'select', ['options' => ['active' => 'Active', 'inactive' => 'Inactive', 'draft' => 'Draft'], 'placeholder' => 'All Status'])
->button('edit', ['icon' => 'edit', 'class' => 'primary', 'position' => 'right', 'params' => ['id' => 'article_id']])
->button('delete', ['icon' => 'delete', 'class' => 'danger', 'position' => 'right', 'params' => ['id' => 'article_id']])
->newButton('New Article', ['icon' => 'add'])
->nowrap(true)
->paginate(5)
->render();| Due | Status | Aktionen | Aktionen | ||||
|---|---|---|---|---|---|---|---|
| RE-2026-001 | Mustermann GmbH | 01.02.2026 | 01.03.2026 | 1.450,00 € | paid | ||
| RE-2026-002 | Tech Solutions AG | 05.02.2026 | 05.03.2026 | 3.200,00 € | pending | ||
| RE-2026-003 | Weber & Partner | 08.02.2026 | 08.03.2026 | 890,50 € | overdue | ||
| RE-2026-004 | Digital Agentur Wien | 10.02.2026 | 10.03.2026 | 5.600,00 € | draft | ||
| RE-2026-005 | Startup Hub Vienna | 12.02.2026 | 12.03.2026 | 2.100,00 € | paid |
| Role | Active | ||
|---|---|---|---|
| Martin Huber | [email protected] | admin | ✓ |
| Anna Schneider | [email protected] | editor | ✓ |
| Thomas Berger | [email protected] | viewer | – |
| Lisa Wagner | [email protected] | editor | ✓ |
| Peter Gruber | [email protected] | admin | – |
List table seit 1.92 · column priority seit 1.92 · who cell seit 1.92
Read in the row, change in the sheet: a click anywhere on a row opens its record beside the list — full screen on a phone. One name cell with two lines, groups instead of a status label per row, and colour only for what needs acting on. The six rules behind it are in the agent skill. size('list') sets the row they describe, and 'priority' lets columns give way by the list's own width — open the sheet or narrow the window and watch "Last seen" go first.
| User | Plan | Last seen | Last 30 days |
|---|---|---|---|
| With access3 | |||
| Pro | 2 min ago | 20 failing | |
| Team | just now | 17 failing | |
| Free | today | — | |
| Without access2 | |||
| Team | never | — | |
| Free | never | — | |
// Rule 4: colour only where something needs acting on — a count that
// is fine stays plain text.
foreach ($people as &$p) {
$p['failing_html'] = $p['failing'] > 0
? '<span class="gk-label gk-label-red">' . (int) $p['failing'] . ' failing</span>'
: '—';
}
unset($p);
(new Table('demo-people'))
->setData($people)
->caption('Users')
->size('list')
->groupBy('access', ['with' => 'With access', 'without' => 'Without access'])
->rowLink(['sheet' => 'demo-person-sheet', 'url' => 'form/f_person_sheet.php'])
->column('name', 'User', ['sub' => 'email'])
->column('plan', 'Plan', ['priority' => 3])
->column('seen', 'Last seen', ['muted' => true, 'priority' => 4])
->column('failing_html', 'Last 30 days', ['format' => 'html', 'priority' => 2])
->toolbar(false)
->render();What is rarely needed folds into one row. The toggle shows and hides the rows its aria-controls names; the chevron and aria-expanded carry the state, so the label never has to change.
| User | Plan |
|---|---|
| Anna Schneider | Pro |
| Thomas Berger | Team |
| Profile: Berlin, 1984 | — |
| Profile: Graz, 1991 | — |
| Profile: Linz, 2002 | — |
echo <<<'HTML'
<div class="gk-table-wrap gk-table-mobile-card">
<table class="gk-table">
<caption class="gk-sr-only">Users and chart profiles</caption>
<thead><tr><th scope="col">User</th><th scope="col">Plan</th></tr></thead>
<tbody>
<tr><td data-label="User">Anna Schneider</td><td data-label="Plan">Pro</td></tr>
<tr><td data-label="User">Thomas Berger</td><td data-label="Plan">Team</td></tr>
<tr class="gk-table-more"><td colspan="2">
<button type="button" class="gk-table-more-toggle" aria-expanded="false" aria-controls="demo-profiles">
<span class="gk-table-more-name">3 chart profiles</span>
<span class="gk-cell-sub">Created from charts — they cannot sign in and have no plan.</span>
</button>
</td></tr>
</tbody>
<tbody id="demo-profiles" hidden>
<tr><td data-label="User">Profile: Berlin, 1984</td><td data-label="Plan">—</td></tr>
<tr><td data-label="User">Profile: Graz, 1991</td><td data-label="Plan">—</td></tr>
<tr><td data-label="User">Profile: Linz, 2002</td><td data-label="Plan">—</td></tr>
</tbody>
</table>
</div>
HTML;| Name | Value | Status |
|---|---|---|
| Widget A | 1.200 € | active |
| Widget B | 340 € | inactive |
| Widget C | 890 € | active |
| Name | Value | Status |
|---|---|---|
| Widget A | 1.200 € | active |
| Widget B | 340 € | inactive |
| Widget C | 890 € | active |
| Name | Value | Status |
|---|---|---|
| Widget A | 1.200 € | active |
| Widget B | 340 € | inactive |
| Widget C | 890 € | active |
$table->size('sm'); // compact
$table->size('md'); // standard (default)
$table->size('lg'); // spacious| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
| Description | Price | Status |
|---|---|---|
| Webdesign Paket | 1.200 € | active |
| Hosting Standard | 9,90 € | active |
| SEO Beratung | 95 € | inactive |
| Logo Design | 450 € | draft |
First column as label/key — ideal for detail views.
| Company name | SSI Schaefer IT Solutions GmbH |
| Founded | 2003 |
| Location | Wien, Oesterreich |
| Employees | 24 |
| Website | ssi.at |
| Status | Active |
$table->variant('default'); // Default
$table->variant('bordered'); // Full border lines
$table->variant('striped'); // Zebra stripes
$table->variant('celled'); // Grid lines around each cell
$table->variant('padded'); // Extra spacing
$table->variant('compact'); // Compact, more rows
$table->variant('selectable'); // Hover cursor, clickable
$table->variant('minimal'); // Only separator, no border
$table->variant('flat'); // Completely flat
$table->variant('inverted'); // Dark table (also in Light Mode)
// A variant is one slot: a second call REPLACES the first, it does not add.
$table->variant('celled')->variant('padded'); // celled is dropped — padded wins
// Variant and size are separate slots, so those two do stack:
$table->variant('striped')->size('sm');Resize the browser window to <768px to see the mobile layout.
| Article No. | Description | Price | Status |
|---|---|---|---|
| ART-001 | Webdesign Paket S | 1.200 € | active |
| ART-002 | Hosting Standard | 9,90 € | active |
| ART-003 | SEO Beratung | 95 € | inactive |
| Article No. | Description | Price | Status |
|---|---|---|---|
| ART-001 | Webdesign Paket S | 1.200 € | active |
| ART-002 | Hosting Standard | 9,90 € | active |
| ART-003 | SEO Beratung | 95 € | inactive |
| Article No. | Description | Price | Status |
|---|---|---|---|
| ART-001 | Webdesign Paket S | 1.200 € | active |
| ART-002 | Hosting Standard | 9,90 € | active |
| ART-003 | SEO Beratung | 95 € | inactive |
// Demo 1: Complete article table with pagination
$table = new Table('articles');
$table->setData($articles)
->search(['article_number', 'name'])
->column('name', 'Description', ['sortable' => true])
->column('net_price', 'Net', ['format' => 'currency'])
->column('tax_rate', 'VAT', ['format' => 'percent'])
->column('is_active', 'Status', ['format' => 'label'])
->button('edit', ['icon' => 'edit', 'class' => 'primary'])
->button('delete', ['icon' => 'delete', 'class' => 'danger'])
->newButton('New Article', ['icon' => 'add'])
->nowrap(true)
->paginate(5)
->render();
// Mobile-Responsive
$table->mobile('card'); // Cards on mobile (default)
$table->mobile('scroll'); // Horizontal Scroll
$table->column('desc', 'Description', ['hideOnMobile' => true]);$form = new Form('article_form');
$form->action('save/process_article.php')
->ajax()
->hidden('article_id', '')
->row()
->field('article_number', 'Article No.', 'text', ['required' => true, 'width' => 8])
->field('name', 'Description', 'text', ['required' => true, 'width' => 8])
->endRow()
->field('description', 'Description', 'textarea', ['rows' => 3])
->row()
->field('unit', 'Unit', 'select', ['options' => ['Stk' => 'Piece', 'h' => 'Hour', 'psch' => 'Flat rate'], 'width' => 5])
->field('net_price', 'Net price', 'number', ['step' => '0.01', 'width' => 5])
->field('tax_rate', 'VAT %', 'select', ['options' => ['20' => '20%', '10' => '10%', '0' => '0%'], 'width' => 6])
->endRow()
->field('is_active', 'Active', 'toggle', ['inline' => true])
->submit('Save')
->render();Local vendor bundle (assets/ckeditor5/), no CDN. Initialization via IntersectionObserver — works in tabs and modals too.
Add 'upload' => '/your/endpoint' and the editor gains a picture button,
drag & drop, paste, size handles and alt text. Without it nothing changes — an upload
button with nowhere to put the file is worse than no button.
Pictures render as a plain <img> inside the paragraph, carrying their size
as an inline style — deliberately not as <figure class="image">,
so the same content survives being sent as an e-mail, where no stylesheet loads and Outlook
does not lay out <figure> reliably.
Try it: click the picture below to get the size handles and the alt-text button.
The upload button works too, but this public demo stores nothing and says so.
Trash icon only appears when a value is set — disappears automatically after clearing.
Uniform height of 44px for all input types. No browser spinner for number.
Searchable Select – Country selection with search field
Multi-Select – Select multiple categories (with chips)
Ajax Select – Customer search via API (min. 2 characters)
// Grid Layout (16 columns)
$form->row()
->field('name', 'Name', 'text', ['width' => 8])
->field('email', 'E-Mail', 'email', ['width' => 8])
->endRow()
->field('agree', 'Accept terms', 'checkbox', ['checked' => true])
->field('payment', 'Payment method', 'radio', [
'options' => ['card' => 'Credit card', 'bank' => 'Bank transfer'],
'value' => 'card', 'inline' => true
])
->field('active', 'Active', 'toggle', ['inline' => true])
->field('volume', 'Volume', 'range', ['min' => 0, 'max' => 100, 'value' => 50])
->field('doc', 'Document', 'file', ['accept' => '.pdf', 'multiple' => true, 'maxSize' => '10MB'])
->field('content', 'Content', 'richtext', ['preset' => 'full'])
->field('datum', 'Date', 'date', ['value' => date('Y-m-d'), 'clearable' => true])
->field('farbe', 'Color', 'color', ['value' => '#6750a4'])
->field('preis', 'Price', 'number', ['placeholder' => '0.00'])Select files via drag & drop or click
Type, size (min/max), count and total size
Image thumbnails directly in the upload zone
Progress indicator, states, errors per file
Document upload with type filter and size limit.
Multi-upload with image thumbnails directly in the zone.
All options combined: types, min/max size, total limit, file count.
Manually cycle queue items through states: pending → uploading → done / error.
Zone with strict limits — PDF only, max. 100 KB.
Styled native color input — color swatch on the left (clickable) + hex field on the right. Swatch and hex value sync automatically, validated as #RRGGBB.
#RRGGBB values are accepted
Wrap any form in .gk-form-compact for reduced spacing. All elements — inputs, selects, toggles, checkboxes — scale down proportionally.
.gk-form-compact<!-- Normal (default) -->
<form>
<div class="gk-field">...</div>
</form>
<!-- Compact -->
<form class="gk-form-compact">
<div class="gk-field">...</div>
</form>
<!-- Also works as wrapper -->
<div class="gk-form-compact">
<div class="gk-card">
<div class="gk-field">...</div>
</div>
</div>
Card grid for structured content — responsive, with header, body and footer.
.gk-cards // Auto-Grid (min 280px) .gk-cards-2 / -3 / -4 // Fixed columns .gk-card .gk-card-header / .gk-card-body / .gk-card-footer .gk-card-link // Clickable Card
$stats = new StatCards('dashboard');
$stats->card('Customers', 248, ['format' => 'number', 'color' => 'blue'])
->card('Revenue', 84250.00, ['format' => 'currency', 'color' => 'green'])
->card('Outstanding', 12480.00, ['format' => 'currency', 'color' => 'red'])
->render();The small parts an admin page used to style itself, as classes. Light and dark come from the roles.
(new StatCards('demo-tiles'))
->compact()
->card('failing', 3, ['color' => 'danger'])
->card('open', 12, ['sub' => 'for 3 days'])
->card('done', 40)
->render();(new Form('demo-settings'))
->field('demo_login', 'Sign-in allowed', 'toggle', ['hint' => 'Takes effect at once, also on running sessions.', 'value' => 1])
->field('demo_lock', 'Lock the account', 'toggle', ['hint' => 'Signs the person out everywhere.', 'danger' => true])
->field('demo_next', 'What happens now?', 'choice', ['value' => 'fix', 'options' => [
'fix' => 'Fix it now',
'later' => ['title' => 'Later', 'hint' => 'Stays on the list until next week.'],
]])
->render();echo <<<'HTML'
<div class="gk-field-static">
<span class="gk-field-static-label">Plan</span>
<span class="gk-field-static-value">Everything</span>
<span class="gk-field-hint">The superuser always has everything.</span>
</div>
HTML;echo <<<'HTML'
<div class="gk-flex gk-flex-col gk-gap-xl">
<div class="gk-flex gk-flex-wrap gk-gap-xl gk-items-center">
<span><span class="gk-dot gk-dot-success" aria-hidden="true"></span> today 08:12</span>
<span><span class="gk-dot gk-dot-warning" aria-hidden="true"></span> 3 days ago</span>
<span><span class="gk-dot gk-dot-primary gk-dot-pulse" aria-hidden="true"></span> running</span>
<span><span class="gk-dot gk-dot-outline" aria-hidden="true"></span> never</span>
</div>
<div class="gk-flex gk-gap-md">
<span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-1" aria-hidden="true">AS</span>
<span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-2" aria-hidden="true">TB</span>
<span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-3" aria-hidden="true">LW</span>
<span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-4" aria-hidden="true">PG</span>
<span class="gk-avatar gk-avatar-sm gk-avatar-initials gk-avatar-tone-5" aria-hidden="true">MH</span>
</div>
</div>
HTML;echo <<<'HTML'
<div class="gk-flex gk-flex-wrap gk-gap-xl">
<span><span class="gk-swatch gk-swatch-1" aria-hidden="true"></span> Model A 41 %</span>
<span><span class="gk-swatch gk-swatch-2" aria-hidden="true"></span> Model B 27 %</span>
<span><span class="gk-swatch gk-swatch-3" aria-hidden="true"></span> Model C 18 %</span>
<span><span class="gk-swatch gk-swatch-4" aria-hidden="true"></span> Model D 9 %</span>
<span><span class="gk-swatch gk-swatch-5" aria-hidden="true"></span> Other 5 %</span>
</div>
HTML;echo <<<'HTML'
<div class="gk-message gk-message-warning">
<span>2 runs are stuck.</span>
<div class="gk-message-actions"><button type="button" class="gk-btn gk-btn-outlined gk-btn-warning gk-btn-touch">Check now</button></div>
</div>
HTML;Container for related content — single, stacked or with header.
A simple segment visually groups related content.
Segments can have a header to describe the content.
Create account and confirm email.
Fill out profile and configure settings.
Create first project and get started.
With shadow — stands out more from the background.
Muted background — for secondary content.
Less padding — for denser layouts.
.gk-segment / .gk-segment-raised / .gk-segment-muted / .gk-segment-compact .gk-segment-padded / .gk-segment-basic .gk-segments > .gk-segment // Stacked
Notices, warnings and status messages for users.
A notch denser and without a margin of its own, for a place that sets its own gaps: the sentence above a chat, a hint beside a form. All four tones work, light and dark; a long address wraps.
.gk-message / .gk-message-info / .gk-message-success .gk-message-warning / .gk-message-error / .gk-message-compact .gk-message-sm // Denser, no margin of its own .gk-message-dismiss // Close button
Collapsible content areas — individually or as a group.
<div class="gk-accordion" data-gk-single>
<div class="gk-accordion-item open">
<button class="gk-accordion-trigger">
<span>Question?</span>
<span class="material-icons">expand_more</span>
</button>
<div class="gk-accordion-content">
<div class="gk-accordion-body">Answer...</div>
</div>
</div>
</div>
// data-gk-single: only one item open at a time
// .open: item open by defaultProfile pictures with initials fallback, status dot and groups.
.gk-avatar.gk-avatar-lg // Sizes: xs, sm, md, lg, xl .gk-avatar-status.online // Status: online, away, busy, offline .gk-avatar-group // Stacked group .gk-avatar-square // Square instead of round
Path navigation for orientation in nested areas.
Page content here...
<nav class="gk-breadcrumb">
<a href="#"><span class="material-icons">home</span></a>
<span class="gk-breadcrumb-sep"><span class="material-icons">chevron_right</span></span>
<a href="#">Products</a>
<span class="gk-breadcrumb-sep">...</span>
<span class="gk-breadcrumb-current">Current Page</span>
</nav>Tab navigation for switching between content areas.
This is the overview — the first tab is active by default.
Detail information is displayed here.
Settings and configuration options.
<div class="gk-tabs">
<div class="gk-tab-nav">
<button class="gk-tab-btn gk-active" data-tab="tab-1">Tab 1</button>
<button class="gk-tab-btn" data-tab="tab-2">Tab 2</button>
</div>
<div class="gk-tab-panel gk-active" data-tab="tab-1">Content 1</div>
<div class="gk-tab-panel" data-tab="tab-2">Content 2</div>
</div>Combination examples showing the full potential of GridKit.
Tabs for main categories, accordion for details — a common pattern in admin UIs.
Dashboard view with status message and data table in a segment.
| Server | Status | CPU | RAM | Uptime |
|---|---|---|---|---|
| Baerli (server8) | Online | 12% | 4.2 GB | 47 days |
| Theo (server7) | Online | 8% | 3.8 GB | 23 days |
| Waldi (server6) | Online | 15% | 5.1 GB | 31 days |
Page navigation — automatically generated by Table, but can also be used standalone.
<nav class="gk-pagination">
<button class="gk-page-btn" disabled>«</button>
<button class="gk-page-btn gk-active">1</button>
<button class="gk-page-btn">2</button>
<button class="gk-page-btn">»</button>
</nav>$chips = new FilterChips('status-filter', 'status');
$chips->chip('', 'All', ['count' => 152])
->chip('active', 'Active', ['count' => 89, 'color' => 'green'])
->render();$years = new YearFilter('year-nav', 'year');
$years->range(2022, 2026)->render();One unified filter/search bar for all tables. Three fixed sections: Status row · Toolbar · Advanced (collapsible). Stops every page from looking different.
TableHeader::make('exp')
->status(fn() => $statusChips->render())
->search('q', $q, 'Search…', ['live' => 'exp-live'])
->filter(fn() => $yearFilter->render())
->filter('<select class="gk-filter">…</select>')
->advanced(fn() => renderDateRange())
->reset('/faktura/expenses')
->render();Built-in formatting for table columns: currency, percent, date, boolean, label, email.
| Currency | Percent | Date | Boolean | Label | |
|---|---|---|---|---|---|
| 1.234,56 € | 20 % | 13.02.2026 | ✓ | paid | [email protected] |
| 99,00 € | 10 % | 28.01.2026 | – | pending | [email protected] |
| 5.500,00 € | 0 % | 01.12.2025 | ✓ | cancelled |
->column('amount', 'Amount', ['format' => 'currency']) // €1,234.56 (follows the locale)
->column('tax', 'VAT', ['format' => 'percent']) // 20 %
->column('date', 'Date', ['format' => 'date']) // Feb 13, 2026 (follows the locale)
->column('active', 'Active', ['format' => 'boolean']) // ✓ / –
->column('status', 'Status', ['format' => 'label']) // Colored label
->column('email', 'E-Mail', ['format' => 'email']) // mailto: LinkTwo ways to show pictures. A gallery looks: tiles that open a viewer. An image picker chooses: the same tiles, but each one is a radio button, and the viewer can pick. Both use the same viewer, so it pages with the arrow keys, closes on Escape and gives the focus back.
Tap a tile to pick it, or Tab into the group and use the arrow keys. The magnifier opens the viewer; Select this image in it picks the picture you are looking at. The status line, the form value and the event log below follow every change. One tile has a small origin chip ("AI"), one a remove button, one has no preview, one is disabled and says why.
The picked tile (21 here) stays visible even though it lies beyond the fold. Resize the window: the number of tiles in the two rows follows the width. On a phone two tiles fit in a row, and thirty images are about four tiles and a button instead of a screen-long list.
| Where | Key | What happens |
|---|---|---|
| Picker | Tab | Enters the group once, on the picked tile; the next Tab reaches that tile's magnifier (and remove button), then the fold button — thirty tiles are not sixty tab stops. |
| Picker | ← → ↑ ↓ | Moves to the next tile and picks it (native radios); disabled tiles are skipped. |
| Picker | Enter on the magnifier | Opens the viewer on that picture. |
| Viewer | ← → | Previous or next picture (all of them, folded ones included). |
| Viewer | Tab | Stays inside: close, previous, next, "Select this image", "Close". |
| Viewer | Esc | Closes it; the focus goes back to the magnifier of the picture last shown — or to the radio, when one was picked from the viewer. |
Picking is the radio's own change event, so a form needs nothing new. A caption button fires gk:pickeraction and does nothing else —
here the page asks "Really?" first (GK.confirm) and only then calls GK.picker.remove(). The library never deletes anything itself.
picker.addEventListener('gk:pickeraction', (e) => {
// e.detail = { action: 'remove', value: 'img-3', item: <the tile>, picker: <the container> }
GK.confirm('Remove this image?', { danger: true }).then((ok) => {
if (ok) GK.picker.remove(e.detail.picker, e.detail.value);
});
});
GK.picker.add(picker, { value: 'img-9', title: 'Fresh upload', thumb: '/t/9.webp', full: '/g/9.webp' },
{ select: true, focus: true }); // picks it as a click would — one change event
// The viewer is open to your own lists too:
GK.lightbox.show([{ src: '/g/1.webp', caption: 'Fireplace', meta: 'Own image' }], 0, openerButton);Image grid with lazy loading, hover overlay and lightbox (arrow keys, Escape).
<div class="gk-gallery">
<div class="gk-gallery-item" data-lightbox="full.jpg" data-caption="Caption" data-lazy>
<img data-src="thumb.jpg" alt="">
<div class="gk-gallery-overlay">
<span class="gk-gallery-caption">Caption</span>
</div>
</div>
</div>
// Variants:
.gk-gallery-sm // Smaller thumbnails (100px min)
.gk-gallery-lg // Larger thumbnails (220px min)
.gk-gallery-masonry // Pinterest layout (columns)
// Lightbox: arrow keys, Escape, click outside to close
// data-lazy: load images only when visibleA frame for a 9:16 video or picture — a short, a story, a phone screenshot. It keeps its shape, is never wider than its column and never taller than 75 % of the window, and shows the whole picture. gk-video-frame-safe draws the zones a platform covers with its own buttons (about the top tenth and the bottom fifth) as guide lines — they never catch a click, so the controls still work. Nothing to show yet, or it broke: gk-video-frame-busy and gk-video-frame-error reuse the empty state. No autoplay.
echo <<<'HTML'
<div class="demo-frames">
<figure class="gk-video-frame">
<div class="gk-video-frame-media">
<video controls playsinline preload="metadata" aria-label="Sample video, 3 seconds" src="media/sample-portrait.webm"></video>
</div>
<figcaption class="gk-video-frame-caption">Version 2 · 3 s</figcaption>
</figure>
<figure class="gk-video-frame gk-video-frame-safe">
<div class="gk-video-frame-media">
<video controls playsinline preload="metadata" aria-label="Sample video with the safe zones shown" src="media/sample-portrait.webm"></video>
</div>
<figcaption class="gk-video-frame-caption">Safe zones shown: top 10 %, bottom 20 %</figcaption>
</figure>
<figure class="gk-video-frame">
<div class="gk-video-frame-media">
<img src="media/sample-portrait.svg" alt="The first frame of the video: a blue field with a pale disc">
</div>
<figcaption class="gk-video-frame-caption">A picture shows in the same frame</figcaption>
</figure>
<figure class="gk-video-frame gk-video-frame-busy">
<div class="gk-video-frame-media">
<div class="gk-empty" role="status">
<div class="gk-empty-inner">
<span class="material-icons gk-empty-icon" aria-hidden="true">movie</span>
<div class="gk-empty-title">Video is being made</div>
<div class="gk-empty-hint">This takes a minute or two. You can leave the page.</div>
</div>
</div>
</div>
<figcaption class="gk-video-frame-caption">Version 3</figcaption>
</figure>
<figure class="gk-video-frame gk-video-frame-error">
<div class="gk-video-frame-media">
<div class="gk-empty">
<div class="gk-empty-inner">
<span class="material-icons gk-empty-icon" aria-hidden="true">error</span>
<div class="gk-empty-title">No video</div>
<div class="gk-empty-hint">The image service did not answer. Try again, or change the topic.</div>
</div>
</div>
</div>
<figcaption class="gk-video-frame-caption">Version 3 failed</figcaption>
</figure>
</div>
HTML;System-wide quick search. GridKit provides the widget only —
WHAT is searched is decided by your backend endpoint. Opens with
Ctrl+K (Cmd+K on Mac) or any element
carrying data-gk-search. Arrow keys select, Enter opens, Escape closes.
GK.search.init({
url: '/demo/api/quicksearch.php',
hotkey: 'ctrl+k',
minLength: 0
});
// Server response. The German key names — gruppen / titel / treffer /
// untertitel / betrag — are still read as a fallback, but this is the shape:
{ "groups": [
{ "title": "Transactions",
"items": [
{ "title": "Hetzner",
"subtitle": "Jul 7, 2026",
"amount": "€64.80",
"url": "/transactions/2",
"icon": "account_balance" } ] } ] }Toast notifications for success, error and info messages. Disappear after 3 seconds.
GK.toast.success('Successfully saved!');
GK.toast.error('Error while saving!');
GK.toast.warning('Warning: Limit reached!');
GK.toast.info('3 new entries');A toast is for the corner; this line says "Saved." where the person is working, and a screen reader reads it. The region is on the page from the start and never hidden — empty, it draws no box. GK.announce() changes only its words, and the same words twice are read twice.
echo <<<'HTML'
<div class="demo-announce-row">
<button type="button" class="gk-btn gk-btn-filled gk-btn-primary" data-demo-announce="Saved." data-tone="success">Save</button>
<button type="button" class="gk-btn gk-btn-outlined gk-btn-danger" data-demo-announce="Could not save — the connection dropped." data-tone="error">Fail</button>
<button type="button" class="gk-btn gk-btn-text gk-btn-neutral" data-demo-announce="">Clear</button>
</div>
<div class="gk-message gk-message-compact gk-announce" id="demo-announce" role="status" aria-live="polite" aria-atomic="true"></div>
<script>
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-demo-announce]');
if (b) GK.announce('demo-announce', b.getAttribute('data-demo-announce'), b.getAttribute('data-tone'));
});
</script>
HTML;A log of steps, one under the other: what ran, in which order, how each step ended. A real ordered list, so the order is the information; each state has its own symbol and a word beside the title, never a colour alone. Small chips carry cost or provider, a failed step shows its message as a block. The ring of the running step turns, and stands still when the person asked for less motion. Light and dark follow from the roles; long addresses break.
Three scenes, 38 words, ends with a question.
Picture 2 of 3.
The image service did not answer after 3 tries (https://example.org/api/v1/images/generate?scene=2&attempt=3, timeout after 30 s).
Needs the pictures.
echo <<<'HTML'
<div class="demo-timelines">
<div>
<h4>A run in progress</h4>
<ol class="gk-timeline" role="list" aria-label="Steps of the run in progress">
<li class="gk-timeline-step gk-timeline-ok">
<span class="gk-timeline-marker" aria-hidden="true"></span>
<div class="gk-timeline-body">
<div class="gk-timeline-head">
<span class="gk-timeline-title">Write the script</span>
<span class="gk-timeline-state">Done</span>
<span class="gk-timeline-time"><time datetime="2026-10-17T09:12:03">09:12:03</time> · 4.2 s</span>
</div>
<div class="gk-timeline-chips"><span class="gk-label gk-label-gray">Claude</span><span class="gk-label gk-label-gray">$0.03</span></div>
<p class="gk-timeline-detail">Three scenes, 38 words, ends with a question.</p>
</div>
</li>
<li class="gk-timeline-step gk-timeline-ok">
<span class="gk-timeline-marker" aria-hidden="true"></span>
<div class="gk-timeline-body">
<div class="gk-timeline-head">
<span class="gk-timeline-title">Record the voice</span>
<span class="gk-timeline-state">Done</span>
<span class="gk-timeline-time"><time datetime="2026-10-17T09:12:08">09:12:08</time> · 11 s</span>
</div>
<div class="gk-timeline-chips"><span class="gk-label gk-label-gray">Text to speech</span><span class="gk-label gk-label-gray">$0.11</span></div>
</div>
</li>
<li class="gk-timeline-step gk-timeline-running" aria-current="step">
<span class="gk-timeline-marker" aria-hidden="true"></span>
<div class="gk-timeline-body">
<div class="gk-timeline-head">
<span class="gk-timeline-title">Make the pictures</span>
<span class="gk-timeline-state">Running</span>
<span class="gk-timeline-time"><time datetime="2026-10-17T09:12:19">09:12:19</time> · 8 s so far</span>
</div>
<p class="gk-timeline-detail">Picture 2 of 3.</p>
</div>
</li>
<li class="gk-timeline-step gk-timeline-waiting">
<span class="gk-timeline-marker" aria-hidden="true"></span>
<div class="gk-timeline-body">
<div class="gk-timeline-head">
<span class="gk-timeline-title">Assemble the video</span>
<span class="gk-timeline-state">Waiting</span>
</div>
</div>
</li>
<li class="gk-timeline-step gk-timeline-waiting">
<span class="gk-timeline-marker" aria-hidden="true"></span>
<div class="gk-timeline-body">
<div class="gk-timeline-head">
<span class="gk-timeline-title">Check the safe zones</span>
<span class="gk-timeline-state">Waiting</span>
</div>
</div>
</li>
</ol>
</div>
<div>
<h4>A run that broke</h4>
<ol class="gk-timeline" role="list" aria-label="Steps of the run that broke">
<li class="gk-timeline-step gk-timeline-ok">
<span class="gk-timeline-marker" aria-hidden="true"></span>
<div class="gk-timeline-body">
<div class="gk-timeline-head">
<span class="gk-timeline-title">Write the script</span>
<span class="gk-timeline-state">Done</span>
<span class="gk-timeline-time"><time datetime="2026-10-17T10:40:11">10:40:11</time> · 3.9 s</span>
</div>
<div class="gk-timeline-chips"><span class="gk-label gk-label-gray">Claude</span><span class="gk-label gk-label-gray">$0.03</span></div>
</div>
</li>
<li class="gk-timeline-step gk-timeline-failed">
<span class="gk-timeline-marker" aria-hidden="true"></span>
<div class="gk-timeline-body">
<div class="gk-timeline-head">
<span class="gk-timeline-title">Make the pictures</span>
<span class="gk-timeline-state">Failed</span>
<span class="gk-timeline-time"><time datetime="2026-10-17T10:40:15">10:40:15</time> · 31 s</span>
</div>
<div class="gk-timeline-chips"><span class="gk-label gk-label-gray">Image service</span></div>
<p class="gk-timeline-error">The image service did not answer after 3 tries (https://example.org/api/v1/images/generate?scene=2&attempt=3, timeout after 30 s).</p>
</div>
</li>
<li class="gk-timeline-step gk-timeline-skipped">
<span class="gk-timeline-marker" aria-hidden="true"></span>
<div class="gk-timeline-body">
<div class="gk-timeline-head">
<span class="gk-timeline-title">Assemble the video</span>
<span class="gk-timeline-state">Skipped</span>
</div>
<p class="gk-timeline-detail">Needs the pictures.</p>
</div>
</li>
<li class="gk-timeline-step gk-timeline-skipped">
<span class="gk-timeline-marker" aria-hidden="true"></span>
<div class="gk-timeline-body">
<div class="gk-timeline-head">
<span class="gk-timeline-title">Check the safe zones</span>
<span class="gk-timeline-state">Skipped</span>
</div>
</div>
</li>
</ol>
</div>
</div>
HTML;Confirm dialogs as clean modals. Promise-based, with danger mode for destructive actions.
GK.confirm('Send invoice?', {
title: 'Send Invoice', confirmText: 'Send'
}).then(ok => { if (ok) GK.toast.success('Sent!'); });
GK.confirm('Really delete?', {
title: 'Delete', confirmText: 'Delete', danger: true
}).then(ok => { if (ok) { /* ... */ } });Modals in four sizes. Loaded via AJAX, backdrop click and ESC close them.
A modal loads a form via AJAX – the most common use case.
A large modal shows a customer list. Clicking "Edit" opens a second modal with the form.
GK.modal.open('Title', 'form/edit.php', {id: 42}, 'small');
GK.modal.open('Title', 'form/edit.php', {id: 42}, 'medium');
GK.modal.open('Title', 'form/edit.php', {id: 42}, 'large');
GK.modal.open('Title', 'form/edit.php', {id: 42}, 'full');
$table->button('edit', ['icon' => 'edit', 'modal' => 'edit_form'])
->modal('edit_form', 'Edit', 'form/edit.php', ['size' => 'large']);use GridKit\Button;
echo Button::render('Save', ['variant' => 'filled', 'color' => 'primary', 'icon' => 'save']);
echo Button::icon('edit', ['variant' => 'filled', 'color' => 'primary']);
echo Button::fab('add');
echo Button::fab('edit', ['extended' => true, 'label' => 'Edit']);Colored labels for status display — also usable standalone, not only in tables.
<span class="gk-label gk-label-green">Active</span> <span class="gk-label gk-label-orange">Draft</span> <span class="gk-label gk-label-red">Error</span> <span class="gk-label gk-label-gray">Archived</span> <span class="gk-label gk-label-blue">Info</span>
Tooltips appear automatically on buttons with a title attribute. Hover over the buttons:
// Tooltip via title attribute (CSS-only, no JS)
Button::icon('edit', ['title' => 'Edit']);
<button class="gk-btn" title="Tooltip-Text">...</button>Placeholder for empty tables or lists.
| Name | Status | |
|---|---|---|
inboxNo entries found | ||
<div class="gk-empty">No entries found</div>
Minimal (title + user with initials only)
$header = new Header();
echo $header->title('Invoices')
->breadcrumb(['Dashboard' => '/', 'Invoices'])
->search('Search...', 'q')
->action(Button::render('New', ['icon' => 'add', 'size' => 'sm']))
->user('Martin Huber', ['avatar' => '...', 'menu' => [...]])
->sticky()->render();Responsive sidebar navigation with groups, icons, badges and mobile toggle. Visible live on this page.
$sidebar = new Sidebar('main');
$sidebar->brand('My Project', 'dashboard', 'v0.7.0')
->group('Modules')
->item('Dashboard', '/dashboard', 'analytics', ['active' => true])
->item('Invoices', '/invoices', 'receipt_long', ['badge' => 3])
->render();
GK.sidebar.toggle(); GK.sidebar.open(); GK.sidebar.close();M3-compliant theme system with 6 themes and Dark/Light Mode.
Surface-container background with on-surface text colors.
Theme::set('ocean', 'dark');
echo Theme::bodyTag('gk-root');
echo Theme::switcher();
GK.theme.set('forest'); GK.theme.toggleMode(); GK.theme.restore();
Everything below this box is a gallery — it shows what exists. If you would
rather see what it is for, examples/invoices/ is a
working invoice manager: create, edit, delete, with search, filter, sort and
paging answered by the server. About 300 lines, no database.
| Amount | Date | Status | ||
|---|---|---|---|---|
| RE-2026-001 | Mustermann GmbH | 2.400,00 € | 01.02.2026 | paid |
| RE-2026-002 | Technik AG | 5.800,00 € | 05.02.2026 | pending |
| RE-2026-003 | Design Studio | 1.200,00 € | 15.01.2026 | overdue |
| RE-2026-004 | Web Solutions | 3.600,00 € | 10.02.2026 | paid |
| RE-2026-005 | Media House | 950,00 € | 12.02.2026 | pending |
skeleton.php is the ready-made scaffolding for a new GridKit project. Just copy, adjust title + navigation, fill in sections — done.
cp /path/to/gridkit/skeleton.php my-project/index.php
Every page follows this structure — from skeleton.php to production.
Theme::set('indigo') · Layout::bodyTag('gk-root')
->brand() · ->group() · ->item(label, url, icon)
->title() · ->fixed() · ->user(name, opts)
<main class="gk-main"> · Tables, Forms, Cards…
<script src="gridkit.js">
Auth protects pages with session-based login. Passwords are stored as bcrypt hashes.
Pure CSS tooltips via data-gk-tooltip attribute. No JavaScript needed.
Rich tooltips use data-gk-tooltip-rich pointing to a hidden element. Supports links, formatting, and stays open on hover.
Rich tooltips support full HTML content including links, images, and interactive elements.
Learn more →Any element with a title attribute gets a GridKit popup — no markup needed. Opt out with data-gk-tip-off.
<!-- Simple tooltip (CSS-only) --> <span data-gk-tooltip="Hello!">Hover me</span> <!-- Position: top (default), bottom, left, right --> <span data-gk-tooltip="Bottom" data-gk-tooltip-pos="bottom">...</span> <!-- Multiline --> <span data-gk-tooltip="Long text here" data-gk-tooltip-wrap>...</span> <!-- Rich tooltip (HTML content) --> <span data-gk-tooltip-rich="#myTip">Hover</span> <div id="myTip">HTML content here</div>