Skip to content

Screens and the page kit

Almost every admin screen has the same top: a title on the left, maybe a button or two on the right, and the content below. The page kit is that layout as components, so you stop re-typing it.

Each component’s props interface is exported under its own name, such as PageProps and NavScreenProps, for when you wrap one in a component of your own.

<Page title="Reports" actions={<Button>New report</Button>}>
<ReportTable />
</Page>

title is required and becomes the page’s single h1 heading, at 15px like the title of a WordPress page. actions renders top right, and className and children do what they always do. When the actions no longer fit beside the title, on a phone for example, they move to a line of their own under it.

subtitle renders as one 13px grey line under the title and the actions, the way WordPress sets the sentence under a page title. The title row is 40px tall even with no actions, so the title and the subtitle sit in the same place on every page. Give the page compact buttons, size="compact", to match the 32px buttons of a WordPress page header.

tabs renders a row of tabs under the title block. See PageTabs below.

aside adds a second column beside the content, for the details that sit next to a record’s main work:

<Page title="Invoice 1042" aside={<InvoiceDetails />}>
<InvoiceLines />
</Page>

On a wide page the content keeps a 560px column and the aside takes the rest of the width, so the aside is the part that grows with the screen. Put the record’s own details in the content and its longer lists, such as a history, in the aside. When the page is too narrow for both, the aside moves below the content and both fill the page. No breakpoint decides this, only the page’s own width. An aside that renders nothing takes no room.

list marks a page whose content is a list, such as a DataViews table. The content then reaches out to the canvas edges, so the list’s row lines run from edge to edge and its own padding lines up with the title. A page with an aside keeps its list inside the main column. Lists with DataViews shows it in full.

<Page title="Users" list>
<UserList />
</Page>

A screen that fills the canvas edge to edge builds its own layout and uses PageTitle directly, so the page still gets exactly one h1:

<PageTitle>Conversations</PageTitle>

It draws the 15px large heading by default. Pass variant to change the text size.

PageTabs and PageTab split a page into sections that each have their own address, like the Subscribers and Settings tabs of a WordPress page:

<Page
title="Users"
list
tabs={
<PageTabs label="User sections">
<PageTab render={<Link to="/users" activeOptions={{ exact: true }} />} current>
Users
</PageTab>
<PageTab render={<Link to="/users/tokens" activeOptions={{ exact: true }} />}>
API tokens
</PageTab>
</PageTabs>
}
>
<UserList />
</Page>

Each tab is a link. Pass your router’s link element as render, or a plain address as href. godmin does not know your router, so it renders whatever link you give it and adds its own class. current marks the tab of the page on screen, and a screen reader hears it as the current page.

current alone decides which tab is underlined. A router link can still mark itself as the current page for screen readers. TanStack Router counts a link to /users as active on /users/tokens too, so without activeOptions={{ exact: true }} a screen reader would hear both tabs as the current page. Pass it on every tab link, as the example does.

label names the tab row for assistive technology. The row is a navigation region, and the rail is one too, so the name tells them apart.

The tabs look like the WordPress tabs: 13px labels 16px apart, the current one underlined by a 2px grey line as wide as its label, and a 1px light line under the row that runs to the canvas edges.

They are links and not the design system Tabs component on purpose. Tabs switches panels inside one page and moves between tabs with the arrow keys. A tab here opens another page, so it is a link, reached with Tab and marked with aria-current, which is how assistive technology expects page navigation to work. godmin copies the look of the WordPress tabs and keeps the behavior of links.

SectionTitle heads a section inside a page, one size step above the field labels, so a section never reads like another label:

<SectionTitle>Identities</SectionTitle>

It renders an h2. Pass level={3} for a section inside a section, which renders an h3 a size smaller. id and tabIndex pass through to the heading, so a screen can name a list after it or move focus to it.

NavScreen renders a drill-down screen: one you enter from a parent screen and leave again, like a settings subsection.

<NavScreen title="Conversations" back={<Link to="/" />}>
<ConversationList />
</NavScreen>

back is the link that leads back up. You pass it as an element rather than a path, because godmin does not know your router. The kit renders your link with a chevron icon inside it and names it with backLabel for assistive technology.

The remaining props are description, actions and footer. A region you leave out renders nothing, not even empty spacing.

Two behaviors differ from Page:

  • The title is an h2, not an h1, because a drill-down is a layer inside a section rather than a new page.
  • The title takes keyboard focus when the screen mounts, so a screen reader user who followed the link hears where they landed, instead of being dropped back at the top of the document.
<ErrorNotice>Reports could not be loaded.</ErrorNotice>

It renders the design system’s error notice and announces the message to screen readers. Use it wherever you would otherwise render a bare error string.

LoadMore renders a load more button for a paginated list, and nothing at all once every page is loaded:

<LoadMore query={reports}>Load more reports</LoadMore>

query needs three members, hasNextPage, isFetchingNextPage and fetchNextPage, a shape exported as LoadMoreQuery. That is what a TanStack Query infinite query looks like, but nothing here imports TanStack Query, so any object with those three works. The button disables itself while a page is loading.

LogList shows items one under the other, such as the dated notes kept on a record. Each LogItem has a header line, with a label at the start and room for actions at the end, then its text, then anything else you add:

<h3 id="notes-heading">Notes</h3>
<LogList aria-labelledby="notes-heading">
{notes.map((note) => (
<LogItem
key={note.id}
aria-label={`Note from ${note.shownAt}`}
label={<LogTime dateTime={note.at}>{note.shownAt}</LogTime>}
actions={
<Button
variant="minimal"
size="compact"
aria-label={`Edit the note from ${note.shownAt}`}
onClick={() => edit(note.id)}
>
Edit
</Button>
}
body={note.text}
/>
))}
</LogList>

aria-labelledby takes the id of the heading above the list, and a screen reader names the list after that heading.

LogTime shows a date or time as small muted text inside a time element. Give it the value in machine readable form as dateTime, and the words your reader sees as children. godmin formats nothing.

The list keeps your order and never sorts, so pass the newest item first when the log reads that way. body is plain text and keeps its line breaks. Put anything richer, such as small “Label: value” lines, in the children under it.

In a narrow column the actions move under the label and stay at the end of the line. No breakpoint decides this, so the list looks right in an aside and in the main column alike.

A list with no items takes no room, so show your own empty sentence beside it. An item with no label and no actions has no header line, so you can swap a form in as its only child while someone edits it.

An item’s aria-label names the item, not the buttons inside it. A screen reader that lists the buttons on the page still shows every one of them as just Edit. So give each action its own name that says which item it acts on, as the example does.

The list holds no state. Which item is being edited or waiting for a confirmation is yours to track, and so is focus.

RepeatRows edits a list of rows, such as visits with a date and a note each. Every row sets its inputs on one line with a move up arrow, a move down arrow and a trash icon at the end, and an add button sits under the list. In a narrow list the arrows and the trash drop under the inputs, still at the end. Wrap a row’s inputs in a godmin-form__row to set them side by side as well.

<RepeatRows
rows={visits}
onChange={setVisits}
blank={() => ({ date: '', note: '' })}
renderRow={(visit, update) => <VisitFields visit={visit} onChange={update} />}
rowLabel={(at) => `Visit ${at + 1}`}
labels={{ add: 'Add visit', empty: 'No visits yet.', moveUp: 'Move up', moveDown: 'Move down', remove: 'Remove' }}
max={10}
/>

The list stays yours. The editor shows rows and hands every change to onChange as a new array. min and max only limit remove and add. They never trim or pad the rows you pass in.

Set the new array right away, not inside startTransition. A click that lands before your render builds on the rows still shown, so the change before it would be lost.

Keep each row object onChange gives you. The editor tells rows apart by identity, so a row rebuilt as a new object counts as a new row and its inputs start over.

When a keyboard user removes a row, focus moves to the row that took its place, or to the row before it when the last row goes. When the only row goes, focus moves to the add button. When the add button makes a row, focus moves into it.

RowControls is the move and remove buttons alone, and useRowKeys is the hook underneath, for a list that needs its own add step. The trash sits a little apart from the arrows, so a press that lands slightly off does not remove a row.

keyFromLabel turns a label someone typed into a key for code. keyFromLabel('Birth date', { style: 'camel' }) answers birthDate, and kebab style answers birth-date. Accents are dropped, and letters such as ß and ø are spelled in plain a to z. When the key is already in taken, the first free number is added, so birthDate becomes birthDate2.

base.css ships a few classes and four variables for layout jobs every admin screen runs into:

Name For
godmin-form A single column form, up to 560px wide
godmin-form--inline A form that is one row, filling its column
godmin-form__row Short fields side by side inside a form
godmin-form__grow The field of a row that takes most of the free room
godmin-empty A centered empty state with breathing room
godmin-table A full width table with collapsed borders, 13px text on 20px lines
godmin-table__actions The narrow trailing cell holding row actions
godmin-table__title The cell naming the record, bold, regular on a list page, its link with no underline
godmin-table-scroll The box a wide table scrolls inside
godmin-list The box around a DataViews list inside a page section, lining its search and cells up with the text around it
godmin-list-overlay An element laid over a list region, such as a drop zone, set after the list
--godmin-canvas-gutter The canvas padding left and right: 24px, 16px below 640px, none on a full bleed canvas
--godmin-canvas-gutter-block The canvas padding above and below: 16px, none on a full bleed canvas
--godmin-rail-width The width of the rail, 300px, set on :root
--godmin-canvas-margin The space around the canvas, 16px, set on :root. Beside the rail the canvas leaves it out on that side, and below 782px it has none

The canvas sets its two gutters on itself, so read them inside the canvas. The rail width and the canvas margin are set on :root, so they reach the toast region too, which sits outside the frame and uses them to stay centred on the canvas.

A form row puts short fields and a button on one line:

<form className="godmin-form godmin-form--inline">
<div className="godmin-form__row">
<InputControl className="godmin-form__grow" label="Title" />
<InputControl label="Due date" type="date" />
<Button type="submit">Add</Button>
</div>
</form>

Each field starts from 160px and they share the rest of the line. The button keeps its own width. When the line is too narrow, the fields wrap onto the next line, and a button left alone sits at the end of its line. A form that is only one row takes godmin-form--inline, so it fills its column instead of stopping at 560px. In a stacked form, a button keeps its own width instead of stretching across the form.

godmin-table and godmin-table-scroll are a pair, and they solve a phone problem: a table wider than the screen drags the whole page sideways. Wrapped like this, the table scrolls inside its own box instead:

<div className="godmin-table-scroll" role="region" aria-label="Reports" tabIndex={0}>
<table className="godmin-table">…</table>
</div>

The scroll rule only activates below 640px. There the actions column, the cell marked godmin-table__actions, stays pinned to the right edge, so a row’s buttons are in view before any scroll. On a desktop the wrapper only draws a focus ring when a keyboard user reaches it, so you can mark up every table this way.

Every godmin-table row takes a light tint under the pointer and while keyboard focus is inside it, so a reader can tell which row a button belongs to. RowControls in the actions cell sit at its right edge, so they stay put when the column widens.

Two details in that snippet matter:

  • tabIndex={0} makes the box focusable, so a keyboard user can scroll to the columns that are out of view. Without it they cannot reach them at all.
  • The rule gives the box position: relative. Without that, absolutely positioned content inside the table escapes the box and widens the page, which is the exact bug the wrapper prevents.