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

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.

Button

An action on this page: send, save, accept. One primary per view; everything else is quiet or ghost.

Variants

States

Sizes: sm, md, lg

Try it

<Button variant="primary" type="submit">Ask for time</Button>
<Button variant="ghost">Not now</Button>
<Button variant="primary" loading={sending}>Sending</Button>

Use it for

  • The action someone came to take, as the one primary button.
  • Quiet for the actions beside it, ghost for a way out: “Not now”, “Cancel”.
  • Danger only for something that removes or cannot be undone.
  • lg where a thumb lands first: the top of a page, a card's main action.

Not for

  • Going somewhere else. That is a link: use ButtonLink.
  • Two primary buttons side by side. If both matter, one of them is quiet.
  • Vague words such as “Submit” or “OK”. Say what happens: “Send request”.

Accessibility

  • Loading keeps focus: it is aria-busy, says “working”, and ignores further presses.
  • Every size grows to a 44px target on a touch screen.
  • The words are the name; an icon beside them is hidden from screen readers.

Exports: Button, Spinner, buttonClass, BUTTON_SIZES

IconButton

A control with no room for words: search, attention, pin. Its label is its whole name.

Try it

Pin toggles; the bell clears its count.
<IconButton label="Attention" icon={<PiBellSimple />} badge={count} badgeLabel="waiting" />

Use it for

  • Toolbars and dense rows where the icon is widely understood.
  • Toggles, with pressed on or off.

Not for

  • The main action on a page. Words are clearer: use Button with an icon.

Accessibility

  • The label is required, and the component refuses to render without one.
  • A badge joins the name, “Attention, 4 waiting”, because a badge inside a labelled button is never read.

Exports: IconButton

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

Show requests
  • 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.

ReactData analysisNeeds youConfirmed by a mentor

Status

AcceptedBeing checkedDeclined
<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

In reviewAcceptedAuditDeclined

Recording

TranscribingReadyFailed

Feedback theme

ProposedPlannedShipped
<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.

3 new12 unread messages240 waitingnew activity
<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.

Cohort 04MentorAlumni · Cohort 01Institution partnerMentorTeaching assistantAI
{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

Joseph Mutua
<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.

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

Useful, 6 people, including youLearned something, 2 peopleThanks, 1 person

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.

Problem:

We couldn't send your request

Your note is saved. Check your connection and send it again.
Done:
Request sent. David will see it in Mentoring.
Warning:

This removes the request for good

Note:
This channel is read-only after the cohort ends. You can still read and search it.
<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

When you ask a mentor for time, the request waits here until they answer.
<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=.

Warning:

Remove David Kamau from Cohort 05?

He loses the cohort's channels at once. You can add him again from this page.
Cancel
---
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.

How would you like to meet?

In a direct conversation.

<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/
How will you take part?
<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.

Applications
SelectNameCohortStatusAppliedActions
Wanjiku MwangiCohort 05Accepted3 NovReview Wanjiku Mwangi
Lucy ChebetCohort 05In review5 NovReview 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.

<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