Components · Forms
TemplatePicker
Choose a template from a gallery: category filters, search, thumbnails, a blank option, a preview pane and arrow-key grid navigation.
Gallery with categories, search, blank and preview
import { useState } from 'react';
import { TemplatePicker, type TemplatePickerTemplate } from '@rookcom/monarch-react/patterns';
type Layout = 'sidebar' | 'hero' | 'grid' | 'list' | 'split' | 'form';
/** A tiny drawn layout in Monarch tokens, standing in for a product's thumbnail. */
function Thumb({ layout }: { layout: Layout }) {
const line = (w: string, strong = false) => (
<span
style={{
display: 'block',
height: 4,
width: w,
borderRadius: 2,
background: strong ? 'var(--mn-color-accent-solid)' : 'var(--mn-color-border-strong)',
}}
/>
);
const box = (h: number) => (
<span
style={{
display: 'block',
height: h,
borderRadius: 3,
border: '1px solid var(--mn-color-border-default)',
background: 'var(--mn-color-bg-surface)',
}}
/>
);
const frame = {
display: 'flex',
gap: 6,
width: '82%',
height: '78%',
padding: 6,
boxSizing: 'border-box',
borderRadius: 4,
background: 'var(--mn-color-bg-surface)',
border: '1px solid var(--mn-color-border-subtle)',
} as const;
const col = { display: 'flex', flexDirection: 'column', gap: 4, flex: 1 } as const;
if (layout === 'sidebar')
return (
<span style={frame}>
<span style={{ ...col, flex: '0 0 28%' }}>
{line('70%', true)}
{line('90%')}
{line('60%')}
{line('80%')}
</span>
<span style={col}>
{box(14)}
{line('80%')}
{line('60%')}
</span>
</span>
);
if (layout === 'hero')
return (
<span style={{ ...frame, flexDirection: 'column' }}>
<span
style={{
display: 'block',
height: 18,
borderRadius: 3,
background: 'var(--mn-color-accent-subtle)',
}}
/>
{line('60%', true)}
{line('85%')}
{line('70%')}
</span>
);
if (layout === 'grid')
return (
<span style={{ ...frame, display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)' }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<span key={i}>{box(16)}</span>
))}
</span>
);
if (layout === 'split')
return (
<span style={frame}>
<span style={{ flex: 1, borderRadius: 3, background: 'var(--mn-color-accent-subtle)' }} />
<span style={col}>
{line('70%', true)}
{line('90%')}
{line('50%')}
</span>
</span>
);
if (layout === 'form')
return (
<span style={{ ...frame, flexDirection: 'column' }}>
{line('40%', true)}
{box(8)}
{box(8)}
{box(16)}
</span>
);
return (
<span style={{ ...frame, flexDirection: 'column' }}>
{line('50%', true)}
{[0, 1, 2, 3].map((i) => (
<span key={i}>{line(`${90 - i * 10}%`)}</span>
))}
</span>
);
}
/** Built lazily: docs files cannot create JSX at module scope. */
const pages = (): TemplatePickerTemplate[] => [
{
id: 'classic',
name: 'Classic',
description: 'Navigation on the side, content on the right.',
category: 'pages',
preview: <Thumb layout="sidebar" />,
badge: 'Built-in',
},
{
id: 'banner',
name: 'Banner',
description: 'A large header, then a list.',
category: 'pages',
preview: <Thumb layout="hero" />,
},
{
id: 'gallery',
name: 'Gallery',
description: 'Cards in a grid.',
category: 'pages',
preview: <Thumb layout="grid" />,
},
{
id: 'split',
name: 'Split',
description: 'Image beside text.',
category: 'pages',
preview: <Thumb layout="split" />,
},
{
id: 'article',
name: 'Article',
description: 'One readable column.',
category: 'docs',
preview: <Thumb layout="list" />,
keywords: ['blog', 'post'],
},
{
id: 'intake',
name: 'Intake form',
description: 'Collect details before a call.',
category: 'forms',
preview: <Thumb layout="form" />,
},
];
const CATEGORIES = [
{ id: 'pages', label: 'Pages' },
{ id: 'docs', label: 'Articles' },
{ id: 'forms', label: 'Forms' },
];
function GalleryDemo() {
const [value, setValue] = useState<string | null>('classic');
return (
<TemplatePicker
aria-label="Page template"
templates={pages()}
categories={CATEGORIES}
blank={{ description: 'Start from an empty page.' }}
value={value}
onValueChange={(id) => setValue(id)}
renderPreview={(t) => (
<div style={{ display: 'grid', gap: 8 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
aspectRatio: '4 / 3',
borderRadius: 8,
background: 'var(--mn-color-bg-muted)',
}}
>
{t?.preview ?? <span style={{ color: 'var(--mn-color-text-muted)' }}>Empty page</span>}
</div>
<strong>{t ? t.name : 'Blank'}</strong>
<span style={{ color: 'var(--mn-color-text-muted)', fontSize: 13 }}>
{t ? t.description : 'Start from an empty page.'}
</span>
</div>
)}
/>
);
}
<GalleryDemo />Usage examples
Compact, without search
import { TemplatePicker } from '@rookcom/monarch-react/patterns';
<TemplatePicker
aria-label="Issue template"
searchable={false}
minCardWidth="8rem"
defaultValue="bug"
templates={[
{
id: 'bug',
name: 'Bug report',
description: 'Steps, expected, actual.',
badge: 'Built-in',
},
{ id: 'feature', name: 'Feature request', description: 'Problem and proposal.' },
{ id: 'task', name: 'Task', description: 'A checklist to finish.' },
{
id: 'spike',
name: 'Research',
description: 'A question and a time box.',
disabled: true,
},
]}
/>Purpose
One gallery for every “start from a template” choice: page layouts, issue and ticket templates, form starters. The product supplies the templates and their thumbnails and decides what choosing one does. Import from @rookcom/monarch-react/patterns.
Use when
- People pick one starting point from several visual options.
- The options benefit from thumbnails, categories or search.
Avoid when
- Two to five short text options: use SegmentedControl or a radio group.
- A long list of named items without visuals: use Select or Combobox.
Design principles
- A real radio group: one choice, one Tab stop, arrows to move, so selection is announced.
- The thumbnail shows, the text tells: previews are decorative and every card has a name.
- Starting from scratch is always an option when
blankis set, and it comes first. - Filters narrow, they never hide the choice silently: the result count is announced and an empty result says so.
- The selected card is marked by an accent ring and a check, not colour alone.
API reference
import { TemplatePicker, templateMatches } from '@rookcom/monarch-react/patterns';| Export | Kind | Rendering | Root class |
|---|---|---|---|
TemplatePicker | component | client | .mn-template-picker |
templateMatches | utility | client | – |
Renders a <div> (ref) with the filters, a role="radiogroup" of role="radio" card buttons, and an optional preview <section aria-label="Preview">.
Templates are { id, name, description?, category?, preview?, badge?, keywords?, disabled? }; onValueChange(id, template) receives null as the template for the blank card.
templateMatches(template, query) is the search rule (every word must appear in the name, a string description or the keywords).
Cards expose data-state="checked|unchecked" and, for the blank card, data-blank.
Generated from the TypeScript declarations of packages/react/src/components/forms/template-picker.
Properties
TemplatePicker
Choose a template from a gallery: category filters, search, a blank option and an optional preview pane. The cards are a radio group with one Tab stop; arrow keys move through the grid by row and column and select as they go. The product supplies the templates and applies the choice.
| Prop | Type | Description |
|---|---|---|
templates required | readonly TemplatePickerTemplate[] | The templates, from the product. |
blank | boolean | TemplatePickerBlank | Adds a "start from scratch" card first. `true` uses the defaults. |
categories | readonly TemplatePickerCategory[] | Category filters, in order. Shown when given (with an "All" filter first). |
children | ReactNode | |
className | string | |
defaultCategory | string | Initial category id. Default: all. |
defaultValue | string | null | Initial selection (uncontrolled). |
id | string | |
messages | Partial<TemplatePickerMessages> | Override visible and announced strings. |
minCardWidth | string | Narrowest a card gets before the grid drops a column. Default `9rem`. |
onValueChange | ((id: string, template: TemplatePickerTemplate | null) => void) | Called with the chosen id and template (`null` for the blank card). |
renderPreview | ((template: TemplatePickerTemplate | null) => ReactNode) | Larger preview of the selected template, shown beside the grid (below it on narrow screens). Receives `null` for the blank card. |
searchable | boolean | Show the search box. Default true. |
value | string | null | Selected template id (controlled); `null` for none. |
Variants
| Prop | Values | Description |
|---|---|---|
blank | 'false''true''{ id, name, description, preview }' | Adds a dashed “start from scratch” card first (id blank by default). |
renderPreview | 'undefined''(template) => ReactNode' | A larger preview of the selected card: beside the grid from 48rem of width, below it otherwise. |
minCardWidth | '9rem (default)''any CSS length' | The narrowest a card gets; the grid fits as many columns as that allows. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Selected | aria-checked="true", data-state="checked" | Accent ring and a check on the thumbnail. |
| Hover | – | Stronger border. |
| Focus | – | Focus ring outside the card. |
| Disabled | disabled | Faded and skipped by the arrow keys. |
| Category on | aria-pressed="true" | Accent-tinted filter chip. |
| No results | – | “No templates match …” under the grid; the count is announced. |
Interaction behaviour
- Clicking a card selects it and calls
onValueChange. Arrow keys move and select, like any radio group. - Category chips and search filter the cards; the blank card always stays.
- If the selected card is filtered out, the selection is kept and the Tab stop moves to the first visible card.
- Applying the template (filling fields, creating a page) is the product’s job, usually behind a Continue button.
Keyboard support
| Keys | Action |
|---|---|
| Tab | Moves into the grid (to the selected card, or the first one) and out of it. |
| Arrow Left / Right | Previous or next card, wrapping; mirrored in right-to-left. |
| Arrow Up / Down | The card above or below in the grid, by the number of columns shown. |
| Home / End | First or last card. |
| Space / Enter | Selects the focused card. |
Disabled cards are skipped. The search box and chips come before the grid in Tab order.
Accessibility
- Role
- radiogroup of radio
- Accessible name
aria-label(default “Templates”) oraria-labelledby.
- Each card is named by the template name and described by its badge and description.
- Thumbnails and the check are
aria-hidden; nothing depends on seeing them. - The filter chips are toggle buttons (
aria-pressed) in a group named “Categories”; the search box is a named searchbox. - The result count is a visually hidden polite status that updates as filters change.
- Verified with axe in jsdom and in Chromium, Firefox and WebKit on /components/template-picker.
Responsive behaviour
- The grid uses as many columns as fit (
minCardWidth), down to one at 320px. - Filters wrap: the search box takes a full line on narrow screens, chips wrap below.
- The preview pane sits beside the grid from 48rem of the picker’s own width (container query) and below it otherwise.
Theme support
- Monarch tokens only:
--mn-color-accent-solidfor the selected ring and check,--mn-color-bg-subtlebehind thumbnails. - Thumbnails are the product’s; draw them with tokens (as the examples do) so they follow light, dark and high contrast.
- Forced-colours mode outlines the selected card in Highlight; transitions stop under
prefers-reduced-motion.
Do and don't
Do
- Give every template a short, distinct name; the name is what screen readers announce.
- Draw thumbnails with tokens or the customer’s brand colour so they look like the real result.
- Confirm with a separate Continue or Use template button when choosing has side effects.
Don't
- Do not apply a template the moment a card is selected; arrow keys select as they move.
- Do not put essential information only in the thumbnail.
- Do not use it for more than a few dozen templates without categories and search.
Related components
- SegmentedControl: Two to five short text options.
- Select: Many named options without visuals.
- SearchInput: The search box used here.
- EmptyState: What to show when there are no templates at all.
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | react |
| Monarch components | SearchInput |
Testing coverage
- Named radiogroup of radio cards, blank first, names and descriptions, disabled card, one Tab stop; axe.
- Click selects (uncontrolled) and reports the id and template; blank reports
null. - Arrow keys move and select, skip disabled cards and wrap; Home and End.
- Up and Down move by the number of columns (layout simulated in jsdom).
- Category chips and search filter; result count announced; no-results message; axe.
- Controlled value and the preview pane, including the blank card.
templateMatchesrules; translated messages; renders on the server.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/template-picker; Cross-browser axe: /components/template-picker |
Tests: packages/react/src/components/forms/template-picker