Components
The TTV UI kit, part by part. Each example is the real component, rendered by the server exactly as the platform renders it; the ones marked “Try it” respond when you press them.
On this page
- Using the kit
- PageHeader and PageTitle
- Button
- ButtonLink
- BackLink and linkClass
- IconButton
- Menu
- Pill and PillNav
- Chip
- StatusChip
- CountBadge
- RoleBadge
- Avatar
- PersonMarker and TemboMark
- TextReaction
- Card
- DescriptionList
- Notice
- EmptyState
- ConfirmStep
- Fields
- FileField, PrefixField and ChoiceCard
- DataTable
- DataList
- Pager
- Sheet and tab file
Using the kit
One import, for React islands and Astro pages alike. In an .astro file, a component without a client directive renders as plain HTML and sends no JavaScript.
---
import { Button, Notice, TextField } from "@/components/platform/ui";
---
<form method="POST">
{error && <Notice tone="danger" title={error} />}
<TextField name="name" label="Your name" required />
<Button variant="primary" type="submit">Save</Button>
</form>Where a component cannot go, such as a link inside server-rendered copy that must look like a button, take its recipe instead:buttonClass({ variant: "primary" }). Never copy a component's classes by hand.
PageHeader and PageTitle
The top of a page: its one Mattone title, a sentence on what the page is for, and the action the page exists for.
PageHeader
Mentor requests
PageTitle alone
Account
---
import { ButtonLink, PageHeader } from "@/components/platform/ui";
---
<PageHeader title="Mentor requests">
The mentors you asked for time, and what each one said.
<Fragment slot="action">
<ButtonLink variant="primary" href="/dashboard/community/mentors">Find a mentor</ButtonLink>
</Fragment>
</PageHeader>Use it for
- Every page, once, at the top: PageHeader when there is a sentence or an action, PageTitle when the title says it all.
- The action the page exists for, as its one primary: “New program”, “Find a mentor”.
- Section headings below it as plain h2s: the base styles set them in Maven Pro bold.
Not for
- A second Mattone heading on the page, or Mattone for a section.
- More than one action in the header. The others belong with what they act on.
- A peach action that leads to an empty list. Lead with what fills it.
Accessibility
- The title is the page's only h1, so a screen reader's heading list starts with it.
- Long words wrap rather than push the page sideways at 320px.
- The action comes after the title and sentence in reading order at every width.
Exports: PageHeader, PageTitle
BackLink and linkClass
The two kinds of plain link: the way up from a detail page to its list, and a link inside a sentence.
BackLink
linkClass, in a sentence
Your request waits in Mentoring until David answers.
<BackLink href="/admin/applications">Applications</BackLink>
<a href="/dashboard/mentoring" class={linkClass()}>Mentoring</a>Use it for
- BackLink above a detail page's title, named as the list it returns to.
- linkClass for a link inside running text: ink like the words around it, set apart by its underline.
Not for
- Peach text for a link. Peach is the one primary action; a link in a sentence is the sentence's ink.
- An arrow typed into the words (“← Back”). BackLink draws it and hides it from screen readers.
Accessibility
- A link is told apart by its underline, not by colour alone.
- BackLink grows to a 44px target on a touch screen.
Exports: BackLink, linkClass
Pill and PillNav
Pills choose what you see. Pill toggles a filter on this page; PillNav moves between views that have their own address.
PillNav: views with their own address
Try it
- Kevin Omondi · A review of a fare-splitting app
- Brian Kiprop · Thirty minutes on SQL joins
<Pill pressed={filter === "open"} count={open} onClick={() => setFilter("open")}>Needs a decision</Pill>
<PillNav label="Moderation lists" items={[
{ label: "Needs a decision", href: "/admin/moderation", current: true, count: 3 },
{ label: "Decided", href: "/admin/moderation?state=DECIDED" },
]} />Use it for
- A few filters over one list.
- Two to five views of a queue, each a link that works without JavaScript.
Not for
- Moving between spaces or whole sections: that is the tab file.
- More than five or six options: use a select.
Accessibility
- Pill is aria-pressed; PillNav marks its current view aria-current=page.
- Counts are read with what they count.
Exports: Pill, PillNav, pillClass
Chip
A small static label: a skill, a source, a status. Not a control.
Status
<Chip tone="ring">Confirmed by a mentor</Chip>Use it for
- Neutral for facts, peach for something that needs the reader, ring only for a mentor's part.
- Success, warning and danger for an outcome, always in words: “Accepted”, never a green dot.
- A stored status (an application's, a recording's) is a StatusChip, so its name and tone are the same on every page.
Not for
- Anything pressable. A chip that looks like a button but does nothing breaks trust; use Pill.
Accessibility
- Long text truncates inside the chip, so a chip never pushes its row wider than the screen.
Exports: Chip, CHIP_TONES
StatusChip
A stored status in its one name and tone, the same for the member and the admin: an application that was APPROVED reads “Accepted” everywhere.
Application
Recording
Feedback theme
<StatusChip domain="application" status={application.status} />
const { label, tone } = statusTone("recording", recording.processingStatus);Use it for
- Any status read from the database: applications, access requests, feedback and its themes, profiles and posts, projects, recordings, tokens.
- statusTone(domain, status) for the name and tone where a chip cannot go, such as a sentence or a table's sort label.
Not for
- Writing a status's word by hand. A second name for the same state is how “Approved” and “Accepted” ended up side by side.
- A status without its word: the tint only repeats what the word says.
Accessibility
- The word is the whole meaning, so the chip reads the same without colour.
- Every name and tone is proven: each stored value has a name, and every tone clears 4.5:1.
Exports: StatusChip, statusTone, STATUSES
CountBadge
How many things are waiting, in peach. A dot when something is new but a number would not help.
<CountBadge count={unread} label="unread messages" />Use it for
- Unread messages, waiting requests: counts of things that need the person.
Not for
- Totals that need nothing, such as members in a channel. Write those as text.
Accessibility
- The number is read with its meaning, “3 new”; past 99 it shows “99+” and reads the true count.
Exports: CountBadge
RoleBadge
The role a person speaks with, beside their name. The server words it; the badge only draws it.
{author.roleBadges.map((badge) => <RoleBadge badge={badge} />)}Use it for
- Next to an author's name in a thread, a request or a list of people.
Not for
- Inventing wording in the page. If a role needs new words, they change on the server, once.
Accessibility
- Abbreviations are said in full: TA is read as “Teaching assistant”.
Exports: RoleBadge
Avatar
A person's photo, or their initials on a colour taken from their id, so they look the same in every list.
Sizes: xs, sm, md, lg, xl
Initials, a mentor's ring, a photo
<Avatar id={person.id} name={person.name} src={person.avatarUrl ?? undefined} mentor={isMentor} decorative />Use it for
- Wherever a person acts or is listed. Mentors wear the ring, as they do on the homepage's map.
Not for
- Colour by position in a list. The colour belongs to the person, from their id.
Accessibility
- Set decorative when the name is written beside it, so it is not read twice.
Exports: Avatar, avatarColour, initials
PersonMarker and TemboMark
The network's shapes: builders are peach dots, mentors teal rings, institutions diamonds. Tembo, the AI, has a mark rather than a face, so it is never mistaken for a person.
<PersonMarker kind="mentor" label="Mentor" />
<TemboMark size="sm" />Use it for
- Telling kinds of people apart at a glance: in a legend, a list, a role.
Not for
- Decoration that suggests something happened. A ring means a mentor, never “checked” or “done”.
- A person's avatar for Tembo, or Tembo's mark for a person.
Accessibility
- Hidden from screen readers unless given a label, because the words beside them usually say it.
Exports: PersonMarker, TemboMark
TextReaction
A reaction in words, not emoji: “Useful · 6”. Words say what a reaction means and keep the conversation's register.
A read-only tally
Try it
<TextReaction kind="useful" count={6} mine={mine} onChange={(active) => react("useful", active)} />Use it for
- The five fixed reactions under a message.
Not for
- A reaction that looks pressable where it cannot be pressed: without onChange it is a plain tally.
Accessibility
- Reads as “Useful, 6 people, including you”, and toggles with aria-pressed.
Exports: TextReaction, REACTIONS
Card
A raised surface on a sheet: a request, an embed, a profile summary. A faint lift in the sheet's own light, never a new colour.
Wanjiku Mwangi
Asked for 30 minutes about an M-Pesa reconciliation project
I have the ledger matching working, but I am not sure my tests cover reversed payments.
<Card as="article" aria-labelledby="request-title">…</Card>Use it for
- One thing a person can read or act on as a unit.
Not for
- Cards inside cards. If a card needs sections, use spacing and hairlines inside it.
Accessibility
- Pick the element for what it is: article for a self-contained item, li in a list.
Exports: Card, RAISED
DescriptionList
Facts about one thing, as name and value: an application's dates, a person's roles, a recording's source.
- Status
- Accepted
- Cohort
- Cohort 05 · Full-stack foundations
- Applied
- 3 Nov
<DescriptionList>
<DescriptionItem term="Status"><StatusChip domain="application" status={status} /></DescriptionItem>
<DescriptionItem term="Applied">3 Nov</DescriptionItem>
</DescriptionList>Use it for
- The facts at the top of a detail page.
- items={[{ term, detail }]} when every detail is plain text; DescriptionItem when one holds a link or a chip.
Not for
- A table for one thing's facts: a table compares many things.
- Boxes around each fact. Hairlines separate them.
Accessibility
- A real dl, so each fact is read with its name.
- The name stacks above the fact on a phone, and long values wrap.
Exports: DescriptionList, DescriptionItem
Notice
A message about the page or what just happened on it, in one of four tones. One per region, at the top of what it is about.
We couldn't send your request
This removes the request for good
<Notice tone="danger" focusOnLoad title="We couldn't send your request">
Check the two fields marked below.
</Notice>
<!-- From .astro, the actions come in a named slot -->
<Notice tone="success" title="Application accepted">
Wanjiku can join Cohort 05.
<Fragment slot="actions"><ButtonLink href="/admin/programs/c5" size="sm">Open Cohort 05</ButtonLink></Fragment>
</Notice>Use it for
- A rejected form (danger, at the top, with errors under each field too).
- A saved change (success).
- A caution before something final (warning).
Not for
- A field's own error: that sits under the field.
- Notices stacked three deep. Say it once, where it applies.
Accessibility
- Failures interrupt (role=alert); confirmations wait their turn (role=status); information is simply part of the page.
- The tone is said in words and shown by an icon, never by colour alone.
- After a rejected submit, focusOnLoad lands the reader on it, with no script needed.
Exports: Notice
EmptyState
The place a list will be. It says what belongs here and how it gets filled, never just “Nothing here”.
No requests yet
<EmptyState title="No requests yet" action={<ButtonLink href="/dashboard/community/mentors">Find a mentor</ButtonLink>}>
When you ask a mentor for time, the request waits here until they answer.
</EmptyState>
<!-- From .astro, the action comes in a named slot -->
<EmptyState title="No requests yet">
When you ask a mentor for time, the request waits here until they answer.
<Fragment slot="action"><ButtonLink href="/dashboard/community/mentors">Find a mentor</ButtonLink></Fragment>
</EmptyState>Use it for
- An empty queue, list or search, in the list's own place on the sheet.
Not for
- Jokes or illustrations that take longer to read than the sentence that helps.
Accessibility
- Its heading level is set to fit the page's outline (h3 by default).
Exports: EmptyState
ConfirmStep
The step between pressing a destructive action and it happening, with no script: the page itself asks, at ?confirm=.
Remove David Kamau from Cohort 05?
---
const asking = confirmingAction(Astro.url, ["remove-mentor"]);
---
{asking ? (
<ConfirmStep title="Remove David Kamau from Cohort 05?" confirmLabel="Remove from cohort"
cancelHref={withoutConfirm(Astro.url)} fields={{ intent: "remove-mentor", userId }}>
He loses the cohort's channels at once.
</ConfirmStep>
) : (
<ButtonLink variant="danger" href={confirmHref(Astro.url, "remove-mentor")}>Remove from cohort</ButtonLink>
)}Use it for
- Anything that removes, revokes or cannot be undone. The action is a link to confirmHref(url, action); the page draws the step when confirmingAction(url, offered) returns it.
- The question names the thing; the danger button says what happens; Cancel goes back with withoutConfirm(url).
Not for
- The browser's confirm(): it needs script and cannot say what will happen.
- A confirm step for something easy to undo. Do it, and offer the undo.
Accessibility
- The warning takes focus when it appears, so the reader lands on the question.
- The buttons sit below the warning, not on its tint, so both read at 4.5:1 at rest and hovered.
- The step only asks: the POST handler still checks who may do this.
Exports: ConfirmStep, confirmHref, confirmingAction, withoutConfirm, CONFIRM_PARAM
Fields
Labelled form controls: a label above, a hint under it, and after a rejected submit an error that says how to fix it.
Mentors reply here.
Enter an email address, like wanjiku@example.com.
A few sentences is plenty.
<form method="POST">
<TextField name="email" type="email" label="Email" hint="Mentors reply here." error={errors.email} required />
<ChoiceGroup name="format" label="How would you like to meet?" options={FORMATS} defaultValue={values.format} />
<Button variant="primary" type="submit">Send request</Button>
</form>Use it for
- Every form, in React islands and in server-rendered Astro forms alike.
- Radios for one of a few answers worth reading side by side; a select past six or seven.
- Mark the fields people may skip as optional, rather than starring the rest.
Not for
- Placeholders as labels: they vanish as soon as someone types.
- Validating while someone is still typing.
Accessibility
- Labels are linked by id, which comes from id or name and is never generated.
- Hints and errors are read with the control (aria-describedby); errors mark it aria-invalid.
- Edges are 3:1 against every sheet; one-line controls are a 44px target.
- A checkbox or radio is drawn 20px but is a 44px target on a touch screen, and the system draws it in forced colours.
Exports: TextField, TextAreaField, SelectField, Checkbox, ChoiceGroup, CONTROL, CONTROL_HEIGHT, CHOICE_BOX, LABEL, HINT, FIELD_ERROR
FileField, PrefixField and ChoiceCard
Three fields for answers the plain ones don't fit: a file, a value that continues a fixed address, and choices that need a sentence each.
An MP4 or WebM, up to 2 GB.
Letters, numbers and hyphens.
tembotechventures.com/talent/<FileField name="file" label="Recording" hint="An MP4 or WebM, up to 2 GB." accept="video/mp4,video/webm" required />
<PrefixField name="handle" label="Your public address" prefix="tembotechventures.com/talent/" defaultValue={values.handle} />
<ChoiceGroup name="intent" label="How will you take part?" appearance="cards" options={INTENTS} />Use it for
- FileField for an upload, with what it takes in the hint: the kinds of file and the size limit.
- PrefixField where the value finishes something fixed: a profile's handle, a post's slug.
- ChoiceGroup appearance="cards" (or ChoiceCard alone) when each answer needs a sentence to be understood.
Not for
- A ChoiceCard for a yes or no: that is a Checkbox.
- Hiding the prefix in a placeholder: it is part of what the person is choosing.
Accessibility
- The file picker is the browser's own, so it works with every keyboard and screen reader.
- The prefix is read with the field (aria-describedby) and wraps above it on a narrow screen.
- A whole ChoiceCard is its label, one large target; the chosen card says so with its box as well as its colour.
Exports: FileField, PrefixField, ChoiceCard
DataTable
Many things side by side: people, applications, recordings. A real table on a wide screen; on a phone each row stacks and names each fact.
| Select | Name | Cohort | Status | Applied | Actions |
|---|---|---|---|---|---|
| Wanjiku Mwangi | Cohort 05 | Accepted | 3 Nov | Review Wanjiku Mwangi | |
| Lucy Chebet | Cohort 05 | In review | 5 Nov | Review Lucy Chebet |
<DataTable caption="Users" columns={[{ label: "Name" }, { label: "Joined", align: "end" }, { label: "Actions", hidden: true }]}
isEmpty={users.length === 0}>
{users.map((user) => (
<DataRow>
<DataCell label="Name" primary><a href={`/admin/users/${user.id}`} class={linkClass()}>{user.name}</a></DataCell>
<DataCell label="Joined" align="end">{formatDay(user.createdAt)}</DataCell>
<DataCell label=""><RowAction href={`/admin/users/${user.id}`} row={user.name}>Open</RowAction></DataCell>
</DataRow>
))}
<Fragment slot="empty"><EmptyState title="No people yet">…</EmptyState></Fragment>
</DataTable>Use it for
- Lists people compare across columns: the admin's users, applications, recordings.
- The first cell names the row and links to it; actions are quiet RowActions named by their row.
- isEmpty with an EmptyState in empty, so an empty list says what fills it.
Not for
- Uppercase or tracked headers: headers are sentence case.
- A primary button in every row. The page has one primary; rows are quiet.
- A table for one thing's facts: that is a DescriptionList.
Accessibility
- Table roles are spelled out, so the table is still a table when it stacks on a phone.
- Each stacked fact is shown with its column's name, which screen readers skip in favour of the real header.
- A selection box is named by its row: “Select Wanjiku Mwangi”.
Exports: DataTable, DataRow, DataCell, DataSelectCell, RowAction
DataList
Things read one at a time rather than compared: notes, themes, requests. A title that links, a line of facts, a status and room for actions.
- Wanjiku Mwangi · 3 Nov · Cohort 05It takes a while before the recording starts, even on wifi.
- Lucy Chebet · 1 Nov
<DataList label="Feedback notes" isEmpty={notes.length === 0}>
{notes.map((note) => (
<DataListItem title={note.summary} href={`/admin/feedback/${note.id}`} meta={`${note.author} · ${formatDay(note.createdAt)}`}>
<Fragment slot="status"><StatusChip domain="feedback" status={note.status} /></Fragment>
</DataListItem>
))}
<Fragment slot="empty"><EmptyState title="No notes yet">…</EmptyState></Fragment>
</DataList>Use it for
- A queue or a feed where each item is read on its own.
- headingLevel to fit the page: 2 straight under the title, 3 under a section.
Not for
- Cards around each item: hairlines separate them.
- More than two actions on an item: open it instead.
Accessibility
- Each item's title is a heading, so a screen reader can move from item to item.
- The list is named (label), so it can be found among the page's landmarks and lists.
Exports: DataList, DataListItem
Pager
The way through a list longer than a page: links to the previous and next pages, and where the reader is.
<Pager previousHref={previous} nextHref={next} summary="21–40 of 133" />Use it for
- Any list that stops: the users, feedback, access requests.
- Cursor or offset pages alike: it only needs the two addresses.
Not for
- “Load more” buttons that need a script.
- A disabled link at an end of the list: leave it out.
Accessibility
- A named nav of plain links, rel=prev and rel=next, so each page has its own address and works without JavaScript.
Exports: Pager
Sheet and tab file
The platform's frame. Each space is a sheet in its own tone; its tabs stand on the sheet like the index tabs of a card file, the active one raised and joined to the page.
What's new
The Community sheet. Cohort waits behind it with two new things.
A TabFile with tabs of its own, borrowing two sheet tones.
<TabFile label="Style guide" active="components" tabs={[
{ id: "principles", label: "Principles", href: "/style-guide", tone: "community" },
{ id: "components", label: "Components", href: "/style-guide/components", tone: "mentoring" },
]}>
…the page…
</TabFile>Use it for
- SpaceFile for the spaces shell: spaces always in the same order, Profile last, each with its glyph.
- TabFile for a handful of sibling pages that each deserve a sheet, such as this guide.
- Sheet alone for a space's page outside the shell.
Not for
- Tabs that swap content without changing the address. Each tab is a link to a page.
- New sheet colours. Borrow a tone: every ink is proven on exactly these.
- More than six tabs. Past that, the row stops being a place you can take in at a glance.
Accessibility
- It is navigation, not an ARIA tab widget: a named nav of links, the active one aria-current=page.
- Nothing in the row covers anything else at any width: the tools move above, inactive tabs fold to their marker, and last the strip scrolls inside its own bounds.
- Under reduced motion, moving between sheets is a plain cut.
Exports: Sheet, TabFile, SpaceFile, SpaceGlyph, SPACES