GridKit v1.65.0

Table

Complete table with all features

UnitNetVATStatusAktionen
ART-001Webdesign Paket Spsch1.200,00 €20%active
ART-002Hosting StandardStk9,90 €20%active
ART-003SEO Beratungh95,00 €20%inactive
ART-004Logo Designpsch450,00 €20%draft
ART-005Newsletter Setuppsch350,00 €20%active
Code anzeigen
$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();

Invoice list with date and currency formatting

DueStatusAktionenAktionen
RE-2026-001Mustermann GmbH01.02.202601.03.20261.450,00 €paid
RE-2026-002Tech Solutions AG05.02.202605.03.20263.200,00 €pending
RE-2026-003Weber & Partner08.02.202608.03.2026890,50 €overdue
RE-2026-004Digital Agentur Wien10.02.202610.03.20265.600,00 €draft
RE-2026-005Startup Hub Vienna12.02.202612.03.20262.100,00 €paid

Compact table without toolbar

E-MailRoleActive
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

Sizes

size('sm') Compact
NameValueStatus
Widget A1.200 €active
Widget B340 €inactive
Widget C890 €active
size('md') Standard
NameValueStatus
Widget A1.200 €active
Widget B340 €inactive
Widget C890 €active
size('lg') Spacious
NameValueStatus
Widget A1.200 €active
Widget B340 €inactive
Widget C890 €active
$table->size('sm');  // compact
$table->size('md');  // standard (default)
$table->size('lg');  // spacious

Display variants

variant('default') Default
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft
variant('bordered') Bordered
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft
variant('striped') Striped
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft
variant('celled') Celled
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft
variant('padded') Padded
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft
variant('compact') Compact
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft
variant('selectable') Selectable
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft
variant('minimal') Minimal
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft
variant('flat') Flat
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft
variant('inverted') Inverted
DescriptionPriceStatus
Webdesign Paket1.200 €active
Hosting Standard9,90 €active
SEO Beratung95 €inactive
Logo Design450 €draft

Definition table

First column as label/key — ideal for detail views.

Company nameSSI Schaefer IT Solutions GmbH
Founded2003
LocationWien, Oesterreich
Employees24
Websitessi.at
StatusActive
$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');

Mobile-Responsive

Resize the browser window to <768px to see the mobile layout.

mobile('card') – Standard
Article No.DescriptionPriceStatus
ART-001Webdesign Paket S1.200 €active
ART-002Hosting Standard9,90 €active
ART-003SEO Beratung95 €inactive
mobile('scroll') – Horizontal Scroll
Article No.DescriptionPriceStatus
ART-001Webdesign Paket S1.200 €active
ART-002Hosting Standard9,90 €active
ART-003SEO Beratung95 €inactive
hideOnMobile – Hide columns
Article No.DescriptionPriceStatus
ART-001Webdesign Paket S1.200 €active
ART-002Hosting Standard9,90 €active
ART-003SEO Beratung95 €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

Grid Layout (16 columns)

Code anzeigen
$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();

Checkbox & Radio

Toggle & Slider

50
75

File Upload

Dateien hierher ziehen oder klickenMax. 10MB

RichText Editor (CKEditor 5)

Local vendor bundle (assets/ckeditor5/), no CDN. Initialization via IntersectionObserver — works in tabs and modals too.

Clearable date/time fields

Trash icon only appears when a value is set — disappears automatically after clearing.

New field types

Uniform height of 44px for all input types. No browser spinner for number.

Select extensions

Searchable Select – Country selection with search field


Multi-Select – Select multiple categories (with chips)

Webdesign
SEO
Hosting
Development
Support
Consulting

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'])

File Upload (extended)

Drag & Drop

Select files via drag & drop or click

Validation

Type, size (min/max), count and total size

Preview

Image thumbnails directly in the upload zone

Queue UI

Progress indicator, states, errors per file

Variant 1 — Simple

Document upload with type filter and size limit.

Datei hierher ziehen oder klickenPDF or Word, max. 10 MB

Variant 2 — Images with preview

Multi-upload with image thumbnails directly in the zone.

Dateien hierher ziehen oder klickenMax. 8 MB

Variant 3 — Full configuration

All options combined: types, min/max size, total limit, file count.

Dateien hierher ziehen oder klickenMax. 10 MB/file · 50 MB total · 10 files

Queue states live

Manually cycle queue items through states: pending → uploading → done / error.

Validation error demo

Zone with strict limits — PDF only, max. 100 KB.

Datei hierher ziehen oder klickenPDF only, max. 100 KB — other files will be rejected

Color Picker

Styled native color input — color swatch on the left (clickable) + hex field on the right. Swatch and hex value sync automatically, validated as #RRGGBB.

Live Demo

Behavior

  • Clicking the color swatch opens the native browser color picker
  • The hex text field shows the current value and is directly editable
  • Both fields synchronize in real time
  • Validation: only valid #RRGGBB values are accepted
  • Uniform 44px height like all other GridKit inputs

Form Density

Wrap any form in .gk-form-compact for reduced spacing. All elements — inputs, selects, toggles, checkboxes — scale down proportionally.

Normal (default)
Compact .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>

Cards

Card grid for structured content — responsive, with header, body and footer.

Auto-Grid (responsive)

Webdesign
Package S · from 1,200 €
Responsive design, CMS integration and SEO basics for small projects.
Hosting
Managed · from 9.90 €/month
SSD storage, daily backups and SSL included. 99.9% uptime.
SEO
Consulting · 95 €/h
Keyword analysis, on-page optimization and monthly reports.

Fixed column count

Performance
99.9% Uptime
Security
SSL & Firewall
Support
Available 24/7
Backups
Daily automatic

2 columns

Development
Frontend and backend development with modern technologies.
Consulting
Strategic IT consulting for digital transformation.

3 columns

Web
Mobile
Cloud
.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

Stat Cards

Customers248
Revenue84.250,00 €
Orders64
Outstanding12.480,00 €
$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();

Layout

Segment

Container for related content — single, stacked or with header.

A simple segment visually groups related content.

Segment with header

Project overview

Segments can have a header to describe the content.

Stacked segments

Step 1

Create account and confirm email.

Step 2

Fill out profile and configure settings.

Step 3

Create first project and get started.

Variants

Raised

With shadow — stands out more from the background.

Muted

Muted background — for secondary content.

Compact

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

Message

Notices, warnings and status messages for users.

A neutral message without a specific status.

Types

Information
Your changes are saved automatically.
Success
The profile has been successfully updated.
Warning
Your SSL certificate expires in 7 days.
Error
Could not establish connection to the database.
  • Host unreachable
  • Timeout after 30 seconds

Compact

Compact message for less space.

Dismissible

This message can be closed.
.gk-message / .gk-message-info / .gk-message-success
.gk-message-warning / .gk-message-error / .gk-message-compact
.gk-message-dismiss  // Close button

Navigation & Filter

Accordion

Collapsible content areas — individually or as a group.

GridKit is a lightweight PHP/CSS/JS framework for admin dashboards and internal tools. Zero dependencies, M3-inspired.
Table, Form, Modal, Cards, StatCards, Sidebar, Header, Tabs, Accordion, Breadcrumb, Toast, Confirm and more. All with Light & Dark Mode.
No. GridKit has no dependencies — one CSS file, one JS file, done. Just include and get started.
<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 default

Avatar

Profile pictures with initials fallback, status dot and groups.

Sizes
XS
SM
MD
LG
XL
Status
MM
Group (stacked)
+3
.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

Gallery + Lightbox

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 visible

Breadcrumb

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>

Tabs

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>

Combinations

Combination examples showing the full potential of GridKit.

Tabs + Accordion (combined)

Tabs for main categories, accordion for details — a common pattern in admin UIs.

3 packages available: S (1,200 €), M (2,400 €), L (3,500 €). All incl. responsive design and CMS.
Managed hosting from 9.90 €/month. SSD, backups, SSL included. 99.9% uptime guarantee.
24 active customers, 3 open inquiries
Mustermann GmbH
Since 2024 · 12 projects
Tech Solutions AG
Since 2023 · 8 projects
Weber & Partner
Since 2025 · 3 projects
Notifications


Segment + Message + Table

Dashboard view with status message and data table in a segment.

Server-Status
All 3 servers are online — last check 2 minutes ago.
ServerStatusCPURAMUptime
Baerli (server8)Online12%4.2 GB47 days
Theo (server7)Online8%3.8 GB23 days
Waldi (server6)Online15%5.1 GB31 days

Pagination (standalone)

Page navigation — automatically generated by Table, but can also be used standalone.

<nav class="gk-pagination">
    <button class="gk-page-btn" disabled>&laquo;</button>
    <button class="gk-page-btn gk-active">1</button>
    <button class="gk-page-btn">2</button>
    <button class="gk-page-btn">&raquo;</button>
</nav>

FilterChips

$chips = new FilterChips('status-filter', 'status');
$chips->chip('', 'All', ['count' => 152])
    ->chip('active', 'Active', ['count' => 89, 'color' => 'green'])
    ->render();

YearFilter

$years = new YearFilter('year-nav', 'year');
$years->range(2022, 2026)->render();

TableHeader v1.10

One unified filter/search bar for all tables. Three fixed sections: Status row · Toolbar · Advanced (collapsible). Stops every page from looking different.

Filter zurücksetzen
Date & amount range
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();

Formatter

Built-in formatting for table columns: currency, percent, date, boolean, label, email.

CurrencyPercentDateBooleanLabelEmail
1.234,56 €20%13.02.2026paid[email protected]
99,00 €10%28.01.2026pending[email protected]
5.500,00 €0%01.12.2025cancelled
->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: Link

Search

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" } ] } ] }

Feedback & Dialogs

Toast

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');

Confirm

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) { /* ... */ } });

Modal

Modals in four sizes. Loaded via AJAX, backdrop click and ESC close them.

Nesting: Modal with form

A modal loads a form via AJAX – the most common use case.

Nesting: Modal with table + sub-modal

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']);

UI Components

Buttons

Variants

Colors – Filled

Colors – Outlined

Colors – Tonal

Colors – Text

Icon-Only

Sizes

States

Icon Position & Special

As Link

Button Group

 

Shapes

FAB (Floating Action Button)

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']);

Labels

Colored labels for status display — also usable standalone, not only in tables.

Active Draft Error Archived Info
<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>

Tooltip

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>

Empty State

Placeholder for empty tables or lists.

NameE-MailStatus
No entries found
<div class="gk-empty">No entries found</div>

Header

Invoices

Minimal (title + user with initials only)

Dashboard

$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();

Sidebar

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();

Themes

M3-compliant theme system with 6 themes and Dark/Light Mode.

Layout Mode

Theme Selection

Live Preview

Card Title

Surface-container background with on-surface text colors.

Color Roles

Primary Secondary Tertiary Error
Primary Container Secondary Container Tertiary Container
Surface Container Highest
Theme::set('ocean', 'dark');
echo Theme::bodyTag('gk-root');
echo Theme::switcher();
GK.theme.set('forest'); GK.theme.toggleMode(); GK.theme.restore();

Examples

A whole application, not a mock-up

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.

Open the example application

Dashboard Demo

Invoices152
Revenue 2026127.840,00 €
Open18.320,00 €
Overdue4.280,00 €
AmountDateStatus
RE-2026-001Mustermann GmbH2.400,00 €01.02.2026paid
RE-2026-002Technik AG5.800,00 €05.02.2026pending
RE-2026-003Design Studio1.200,00 €15.01.2026overdue
RE-2026-004Web Solutions3.600,00 €10.02.2026paid
RE-2026-005Media House950,00 €12.02.2026pending

Skeleton — Starting point for new projects

skeleton.php is the ready-made scaffolding for a new GridKit project. Just copy, adjust title + navigation, fill in sections — done.

Quick start:
cp /path/to/gridkit/skeleton.php my-project/index.php

Anatomy of a GridKit Page

Every page follows this structure — from skeleton.php to production.

Config
SIDEBAR
Header
Content
Footer · JS
Configuration
Theme::set('indigo') · Layout::bodyTag('gk-root')
Sidebar
->brand() · ->group() · ->item(label, url, icon)
Header
->title() · ->fixed() · ->user(name, opts)
Content
<main class="gk-main"> · Tables, Forms, Cards…
Footer
<script src="gridkit.js">

Auth — Login Protection

Auth protects pages with session-based login. Passwords are stored as bcrypt hashes.

Auth::protect(); // Protect page Auth::login($user, $pass); // Login attempt Auth::logout('login.php'); // Clear session Auth::check(); // Logged in? Auth::user(); // Username Auth::hashPassword('abc'); // bcrypt hash Auth::renderLogin([...]); // Login page

Open the login page live demo:

Open Login Demo

Credentials: demo / demo

Tooltip

CSS-only Tooltips

Pure CSS tooltips via data-gk-tooltip attribute. No JavaScript needed.

Top (default) Bottom Left Right

Multiline Tooltip

Hover for multiline

Rich Tooltip (HTML content)

Rich tooltips use data-gk-tooltip-rich pointing to a hidden element. Supports links, formatting, and stays open on hover.

Hover for details
GridKit Tooltip

Rich tooltips support full HTML content including links, images, and interactive elements.

Learn more →

Tooltip on Buttons

Global title tooltip (GK.tip, since v1.23.0)

Any element with a title attribute gets a GridKit popup — no markup needed. Opt out with data-gk-tip-off.

Element with title Multiline Opt-out

Usage

<!-- 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>

Changelog

1.65.0]2026-08-30
Added — density as a variant, not a decision made for everyone A table's rhythm is now three tokens instead of numbers scattered through the rules: `--gk-table-pad-y`, `--gk-table-pad-x`, `--gk-label-radius`. **The defaults are exactly what tables looked like before, so nothing moves** until something redefines them. Two variants do, via `data-gk-density` on `<body>` — set it with `Theme::density('konsole')`, or leave it alone for the default: | | rows | status pills | |---|---|---| | default | 12px / 16px | fully round | | `konsole` | 8px / 12px | 4px radius | | `weit` | 16px / 20px | fully round | `konsole` fits roughly a third more rows on the same screen — what someone who works in the tool all day wants, and exactly wrong for someone who opens one page a week. That is a decision per installation, which is why it is a variant and not the new default. It names no colour, so it composes with any theme and with either mode. An unknown value is ignored rather than stamped onto the page. ---
1.64.0]2026-08-30
Changed — the project lives at gridkit.at `gridkit.ssi.at` read as a subfolder of a company rather than a thing with its own identity, which matters for a library someone is deciding whether to depend on. Every reference moved: `composer.json`, the README, `llms.txt`, the sitemap, `robots.txt`, `Layout`, the skill document and the announcement drafts. `CHANGELOG.md` is deliberately untouched. It is a record of what was true when it was written, and rewriting history to match the present is how a changelog stops being worth reading. The old name keeps working — a permanent redirect, path for path, not a switch-off. It is in two emails already sent, in the Packagist metadata of every release up to 1.62, and in whatever else has quietly linked it. R=301 also transfers what authority the subdomain had rather than starting the new name at zero. Timing: this is the cheapest it will ever be. A search for the project today returns Packagist and not the site, so there is almost nothing to migrate. Added — the demo can be found and shared It had a description and nothing else. No canonical, so `?lang=de`, `?lang=en` and every cache-busting query a visitor arrived with counted as separate pages competing with one another. No Open Graph, so sharing the link — the one page that actually shows what GridKit does — produced a bare URL with no title and no picture. It now carries a canonical that follows the active language, `hreflang` alternates for both plus `x-default`, a full Open Graph and Twitter card, and `robots`. The title lost its version number: a title that changes every release gives a search engine a moving target for one URL. The landing page gained `twitter:image`. Twitter falls back to `og:image` when it is absent, but not every client does, and those showed a card with no picture. ---
1.63.0]2026-08-29
Added — `ci/parity.php`, a harness for the bug this project keeps having Three releases in a row fixed the same shape of defect: the table is rendered by PHP and again by JavaScript, and the two had drifted. Hardcoded `de-DE` in 1.55.0, missing `scope` and `aria-sort` in 1.59.0, an unwrapped confirmation in 1.62.1. None was visible in the source of either side. Each appears only when you render once, change something, and render again. So the harness does that. It renders six tables server-side — plain, sortable, formatted, with row buttons, selectable, filtered — captures each one's markup **before gridkit.js loads**, forces a client rebuild, and compares the element and attribute vocabulary of the two. It is not part of `php tests/run.php`. The suite runs on plain PHP with no dependencies, and requiring a browser would break that promise for everyone who clones the repo. Run it when touching either renderer. **Two mistakes in the harness itself, both instructive.** The first version compared position by position, so one element added by the rebuild shifted everything after it and reported a whole table as divergent. The second — worse — snapshotted inside the comparison, which runs after load, by which time the library has already re-rendered every `setData()` table. It compared the client against itself and reported perfect agreement while a button in the DOM carried a raw, unwrapped `onclick`. Both were caught by deliberately reintroducing a known bug and checking the harness noticed. It did not, until fixed. Fixed — the two pagers announced themselves differently Found by the harness on its first honest run. The server named its controls from `pagination.*` and the client from a separate `js.*` pair, so a screen reader heard "Previous" and "Page 1 of 3" on load, then "Previous page" and "Page 1" after the first sort. `Lang::jsConfig()` now exports `pagination.*` alongside `js.*`, `action.*` and `format.*`, and the client reads it. The divergence count across the six cases fell from 121 to 61, and what remains is documented in the harness as benign. Fixed — `immutable` caching on unstamped assets Yesterday's cache fix gave every CSS and JS file a year of `immutable`. That is safe for `Layout::asset()`, which appends `?v=<mtime>` so a changed file is a changed URL — and unsafe for anything linking `/js/gridkit.js` bare, which would then keep last year's script through every deployment. It bit this project's own harness within the hour: the fixture linked the script unstamped, was served a copy from before that morning's fixes, and reported a regression that had been fixed hours earlier. `immutable` now applies only to a URL that carries a `v=` parameter; everything else gets an hour. ---
1.62.1]2026-08-29
Fixed — a delete that stopped asking A row button given both `onclick` and `confirm` asked before acting on the server-rendered page, and from the first sort, filter or page change onwards acted without asking. Anyone who trusted the prompt lost a record to a mis-click. `Table::renderButtons` wraps the handler in `GK.confirm(...)`, with a comment explaining why: an inline handler runs before any delegated listener could stop it, so the `data-gk-confirm` attribute the delegated path reads cannot hold it back. The client renderer draws the same button and set the handler raw. That is the third instance of one defect: the same output rendered in two places, drifting apart. The hardcoded `de-DE` in 1.55.0, the table header attributes in 1.59.0, this. Each was invisible in the source of either side — they only appear when you render one, change something, and render again. The test pins both sides, so a change to either is caught. Verified by reverting the fix and watching it fail. ---
1.62.0]2026-08-29
Two review passes, one over the PHP and one over the rendered pages. Both found real defects; the visual pass found them by measuring in a browser, where reading the CSS would not have. Fixed — the active page button was invisible The client-side pager ADDED `gk-btn-filled gk-btn-primary` on top of `gk-btn-text gk-btn-neutral` rather than replacing it. Two variants on one button fight in the cascade: the text variant, declared later, won the colour; the filled variant won the background. Slate on grey, **1.19:1**, with square corners where the server renders a pill. It now emits one variant, matching what `Pagination` sends: **9.56:1**. Fixed — a table header that outranked its own inverted variant `.gk-table thead th` has specificity 0,1,2 and `.gk-table-inverted th` has 0,1,1, so the light-mode text colour won while the dark background still applied: **2.36:1**. Now **6.96:1**. Fixed — fill colours used as text `.gk-bool-yes` drew its tick in `--gk-success` at **2.54:1** and `.gk-required` its asterisk in `--gk-danger` at **3.67:1**. Both now use the `-text` variant the rest of the library already uses for this, reaching 5.87:1 and 5.67:1. Fixed — white on primary, in dark mode, in every theme Four inline styles in the demo paired `background: var(--gk-primary)` with a hardcoded `color: #fff`. In dark mode the primary flips light and the pairing gives **1.78–1.97:1** — in all six themes. `var(--gk-on-primary)` is identical to white in light mode, so nothing changes there, and dark mode goes to 7.57–7.86:1. Fixed — header actions painted over the avatar The mobile rule shrank `.gk-header-right` but not its children, so at 320px a 33px box held 63px of buttons and overlapped the user menu by 17px. It scrolls now instead of colliding. Fixed — the landing page's terminal demo never ran `runDemo()` read the implicit global `event`, which is undefined when an IntersectionObserver calls it. It threw before printing its first line, and the `observer.disconnect()` after it never ran. Zero lines became thirteen, and the console is clean. Fixed — muted text on the landing page at 2.45:1 `--gk-text-muted: #94a3b8` in six places on white. Now `#64748b`, which is what the same palette already uses in dark mode. `.skill-desc` was worse at **1.93:1** — a light-mode token used inside the dark agent section. Fixed — two horizontal overflows at 320px `minmax(280px, 1fr)` forces a track wider than its container; a bare `<table class="gk-table">` had no scrolling ancestor. Nine and sixty pixels of body scroll respectively, both gone. A note on measuring The first contrast sweep reported 61 failures that were not real: the numbers were read mid-transition, with `oklab()` intermediates. Disabling transitions was required to get truthful values. The same trap caught the verification of this changelog — `getComputedStyle` returns `oklch()` for these themes, which a regex-based luminance parser silently mangles. Resolving the colour through a canvas pixel is the reliable way. ---
1.61.0]2026-08-29
Added — the demo shows the code that produced each example Fomantic UI, Bootstrap and Tailwind UI all put the source under the example. For GridKit it earns more than it does for them, because the claim being made is "fifteen lines of PHP and you have this table" — and until the reader can see the fifteen lines, that is something to take on faith. `showcase()` wraps an example in a closure, then reads the closure's body back out of the file using the line numbers PHP itself reports for it. What a reader sees is the code that just ran, by construction. The `use (...)` clause is dropped — it is plumbing for the demo page, not part of what anyone would write — and the body is dedented to column zero. This replaces the pattern the demo had been using: hand-written blocks beside the examples, like `$table->size('sm'); // compact`. Those are a second copy of the truth, and the copy is the one that rots. Two examples are converted so far — the full-featured table and the 16-column form — because those are the two where the claim needs the evidence. A list of avatar sizes does not. The disclosure is a `<details>`: keyboard-operable and announced as an expandable section with no ARIA of its own, which a div with a click handler is not. The copy button takes its colours from the theme tokens; the first version was styled white-on-dark and turned out invisible, because the demo's code blocks render light in light mode. Fixed — the site told every browser to cache it for a month Something server-wide on this host sends `Cache-Control: max-age=2592000` on everything, HTML included — it is in no active Apache config that could be found, and it applies to the other sites on the machine too. On a site that changes daily the effect is that a returning visitor sees the copy they first loaded, for thirty days. It is why the landing page still showed v1.54.0 in a browser while the server had 1.60.1 on disk, and why every check during this work needed a cache-busting query to tell the truth. `.htaccess` now overrides it for GridKit only: HTML revalidates on every request, while assets keep a year — every asset URL already carries `?v=<mtime>`, so a changed file is a changed URL. ---
1.60.1]2026-08-29
Fixed — the status dot was a crescent `.gk-avatar` carried `border-radius: 50%` together with `overflow: hidden`, and `.gk-avatar-status` is absolutely positioned at `bottom: 0; right: 0` — the corner of the square the avatar occupies, which on a circle lies outside the shape. The round clip sliced the badge into a crescent: a small leaf stuck to the avatar rather than a status dot. The clip only ever existed to round a photograph, so the photograph now rounds itself (`border-radius: inherit` on `.gk-avatar img`) and the avatar no longer clips anything. The badge renders whole, in all four states. Checked while there and deliberately not changed: the stacked avatar group looks sliced at a glance, but that is the overlap doing its job — 40px circles at `-8px` with a white ring, each covering the edge of the next. ---
1.60.0]2026-08-28
Sidebar and header. The worst of the three rounds, because what it found was not a degraded experience — it was a control that could not be operated at all. Fixed — the user menu could not be opened without a mouse The header's user menu, the one containing **Sign out**, is a `div` with `role="button"` and `tabindex="0"`. Only `click` was handled, and a `div` does not synthesise a click from Enter or Space the way a real button does. So it took focus, announced itself as a button, and then did nothing. There was no way to reach Sign out from the keyboard. `aria-expanded` made it worse rather than better: it was written once as `"false"` and never updated, so it stated the opposite of the truth for as long as the menu was open. An attribute that lies is worse than one that is missing. Enter and Space now open it, `aria-expanded` follows the actual state, Escape closes it and returns focus to the trigger, and a click on an item inside the open menu no longer counts as a toggle — which had closed and immediately reopened it. Fixed — the sidebar never said which page you were on The active item carried an `active` class and nothing else. On screen that is a colour; to a screen reader every page presented the same undifferentiated list of links. It carries `aria-current="page"` now. Fixed — two navigations with the same name The sidebar's `<nav>` was unnamed, and since 1.59.0 the table's pager is a `<nav>` too. A screen reader listing landmarks read "navigation, navigation". Both are named now. Fixed — a badge that was a number with no noun `Invoices 3` — three of what? The eye reads the count from context. A screen reader gets a bare digit stuck to the end of a link name. A visually hidden word after the number turns it back into a sentence. Checked and found sound The theme swatches already carried `aria-pressed` and proper labels. The header is a real `<header>` with an `<h1>`, and its menu toggle was correctly named and already reported `aria-expanded`. Sidebar icons were already `aria-hidden`. Not everything was broken. ---