# Keston UI > The Vue 3 component library for every Keston product (`@thekestongroup/ui`), built on Reka UI and Tailwind CSS v4. Every page below is Markdown. Component pages include when to use the component, its behaviour and accessibility rules, and its exact TypeScript API from the source. Use the library's components and tokens rather than building new ones. --- Examples of whole pages built with the library (Vue source): - [Keston ID pages](https://raw.githubusercontent.com/thekestongroup/ui/main/src/examples/KestonIdPages.stories.ts): Sign-in leading to OAuth consent, two-step code (Vue source) - [App pages](https://raw.githubusercontent.com/thekestongroup/ui/main/src/examples/AppPages.stories.ts): Whole app pages built from the components (Vue source) - [School pages](https://raw.githubusercontent.com/thekestongroup/ui/main/src/examples/SchoolPages.stories.ts): Records, reviews, booking, settings (Vue source) - [Form layout](https://raw.githubusercontent.com/thekestongroup/ui/main/src/examples/FormLayout.stories.ts): A validated form (Vue source) --- # Getting started Keston UI is published on npm as `@thekestongroup/ui`. It needs Vue 3.5 or later and Tailwind CSS v4. ## Install ```sh pnpm add @thekestongroup/ui ``` ## Add the theme In your app's main CSS file, after Tailwind: ```css @import "tailwindcss"; @import "@thekestongroup/ui/theme.css"; ``` `theme.css` adds Keston's colours and font (Lato, served with the library, so no requests to Google). It also tells Tailwind to scan the library's components for the classes they use. ## Use a component ```vue ``` Every component is typed, so your editor shows its props and what they do. ## Find your way around - **[Components](https://ui.keston.co/components/button.md)**: one page per component, with a live example, when to use it, its props, and how it behaves for keyboard and screen-reader users. - **[Storybook](https://ui.keston.co/storybook/)**: every component with controls for each prop, plus whole example pages built from them. - **[Changelog](https://ui.keston.co/changelog.md)**: what changed in each version. ## Versions Keston UI follows [semver](https://semver.org). Before 1.0, a minor version (0.3 → 0.4) can include breaking changes, which the [changelog](https://ui.keston.co/changelog.md) points out. --- # Colours Keston's colours are Tailwind theme tokens, defined in `theme.css`. Use them by name in classes, e.g. `bg-primary`, `text-red`, `border-black/15`. Avoid raw hex values, so every product stays in step when a colour changes. ## Brand and text | | Token | Hex | Use for | |---|---|---|---| | | `primary` (`kblue`) | `#023750` | Keston blue: primary buttons, selected items, the brand | | | `black` | `#050505` | Text. Use opacity for quieter text: `text-black/65` | | | `white` | `#FAFAFA` | Cards, fields, text on dark colours | ## Surfaces | | Token | Hex | Use for | |---|---|---|---| | | `background` | `#E1E1E1` | The app's frame behind the content: AppShell, Header, Sidebar | | | `surface` | `#EBEBEB` | Quiet areas inside content | | | `surface-elevated` | `#F2F2F2` | Areas raised above a surface | ## Status | | Token | Hex | Use for | |---|---|---|---| | | `green` | `#007A16` | Success | | | `yellow` | `#F1C70B` | Warnings. Use black text on it | | | `red` | `#D2042D` | Errors and destructive actions | | | `blue` | `#035483` | Information, e.g. an info Badge or Notification | | | `focus` | `#007FC9` | The keyboard focus ring, and nothing else | ## Lines and quieter text Borders and dividers are black at 15% (`border-black/15`). Secondary text is black at 65% (`text-black/65`), and placeholders at 55%. Using opacity, not extra greys, keeps them right on any of the surfaces above. --- # Typography Keston UI uses **Lato** in regular (400) and bold (700), with italics. It's served with the library from `theme.css`, so it works everywhere, including Safari, with no requests to Google. ## Sizes The components use Tailwind's text sizes: | Class | Size | Used for | |---|---|---| | `text-xs` | 12px | Helper text, field messages, small buttons, captions | | `text-sm` | 14px | Most interface text: labels, fields, medium buttons, tables | | `text-base` | 16px | Body text, large buttons, card titles | | `text-lg` | 18px | Panel titles, e.g. a Slideover's | | `text-xl` | 20px | Page and sign-in headings | On touch screens, form fields use 16px text: iPhone zooms the page into any field with smaller text when it's tapped. ## Weight Use bold (`font-bold`) for headings, names and totals. Everything else is regular. There's no semibold or light: two weights keep the hierarchy clear. ## Writing - Sentence case everywhere: "Add a student", not "Add A Student". - Say what something does: "Save changes", not "OK". - Keep labels short, and put guidance in helper text. --- # AppShell > The layout of every Keston app page: the shared Header on top, the app's Sidebar on the left, the page in the middle. - Import: `import { AppShell } from '@thekestongroup/ui'` - Group: Layout - Live example: https://ui.keston.co/storybook/?path=/story/components-layout-appshell--default - Source: https://github.com/thekestongroup/ui/blob/main/src/components/AppShell/AppShell.vue ## When to use - As the root of every app, so all apps share the same header, account menu and responsive behaviour. Each app supplies only its title, sidebar items and pages. ## When not to use - Sign-in, error or marketing pages that have no navigation. ## Anatomy | Part | Required | Notes | |---|---|---| | skip link | Yes | "Skip to content", visible only when focused. | | header | Yes | Header: sticky, with menu button and account menu. The menu button opens the mobile menu (narrow) or collapses the sidebar (wide). | | sidebar | Yes | Sidebar beside the page (wide) or in a left Slideover (narrow). | | main | Yes | The page. Default slot. | ## Properties | Property | Type | Default | Description | |---|---|---|---| | title | string | — | App name, in the header and as the mobile menu's title. | | user | HeaderUser | — | Signed-in user, for the account menu. | | userActions | HeaderUserAction[] | all | Account menu actions. | | signIn | boolean | true | Show Sign in when signed out. | | items / sections | SidebarItem[] / SidebarSection[] | [] | Sidebar contents. | | collapsible | boolean | true | Wide screens: the header's menu button collapses the sidebar. | | collapsed | boolean (v-model) | false | Sidebar collapsed to icons. | | menuOpen | boolean (v-model) | false | Mobile menu open. | | linkComponent | string \| Component | "a" | For sidebar items with `to`. | | header / sidebar | object | — | Other Header / Sidebar props. | Events: `account-settings`, `switch-account`, `log-out`, `sign-in`. ## Spacing and layout - Breakpoint: lg (64rem). Below it the sidebar moves into a Slideover (max width spacing 72). - The page scrolls, not an inner box: the header sticks at the top, the sidebar sticks below it at full remaining height. - Page padding is the page's own (e.g. `ui.main: 'p-6'`). ## Behaviour - Narrow: menu button opens the menu. Choosing an item, or the screen becoming wide, closes it. - Wide: sidebar collapse state is kept by `v-model:collapsed` (the app can store it). ## Accessibility - Landmarks: header (banner), nav (inside the sidebar), main. - Skip link moves focus to main. - Mobile menu is a dialog titled with the app name. ## Platform adaptation | Platform | Implementation | Differences | |---|---|---| | Web / desktop (Vue) | `AppShell.vue` | — | | iOS (SwiftUI) | — | NavigationSplitView (iPad), TabView (iPhone). | | Android (Compose) | — | Scaffold with TopAppBar and NavigationDrawer / Rail. | ## Tokens used | Token | Used for | |---|---| | color background | page background | ## Do / Don't | Do | Don't | |---|---| | Use one AppShell at the root of each app. | Rebuild the header per app. Use slots for app-specific additions. | ## API (from the source) The component's exact props, model, events, slots and types, with their documentation comments. ```ts // AppShell.vue /** * The layout of a whole app page: the Header across the top, the app's * Sidebar down the left, and the page in the middle. Every app uses this, so * the header looks and behaves the same everywhere; only the sidebar's items * and the page change. * * - Wide screens (lg and up): the sidebar sits beside the page, and the * header's menu button collapses it to icons and back (unless * `collapsible` is false). * - Narrow screens: the sidebar is hidden and the menu button opens it in a * Slideover from the left, whose title bar is the header's height. Choosing * an item, or the screen becoming wide, closes it. * - The page itself scrolls (not an inner box), so the header stays at the * top (sticky), the sidebar stays put below it, and mobile browsers can * hide their address bar while scrolling. * * The account menu (top right) is the Header's: pass `user` and listen for * account-settings, switch-account, log-out (and sign-in when signed out). * * Slots: default (the page), header-leading, header-middle, header-trailing, * account, account-menu (passed to Header), sidebar-header and sidebar-footer (passed to Sidebar; * sidebar-header is only shown on wide screens, as the Slideover has its own * title). */ export interface AppShellProps { /** Name of the app, shown in the header and as the mobile menu's title. */ title?: string /** The signed-in user, for the account menu. Without it, Sign in is shown. */ user?: HeaderUser /** Which actions the account menu offers. Default all three. */ userActions?: HeaderUserAction[] /** Show Sign in when there is no user. */ signIn?: boolean /** Sidebar items in a single section. */ items?: SidebarItem[] /** Sidebar items in labelled sections. */ sections?: SidebarProps['sections'] /** Wide screens: show the header's menu button, which collapses the sidebar to icons. */ collapsible?: boolean /** Component used for sidebar items with `to`, e.g. RouterLink. */ linkComponent?: SidebarProps['linkComponent'] /** Other Header props, e.g. `{ ui: { title: '…' } }`. */ header?: Omit /** Other Sidebar props, e.g. `{ label: 'Admissions' }`. */ sidebar?: Omit class?: HTMLAttributes['class'] /** Extra classes for the parts. */ ui?: { main?: HTMLAttributes['class'] } } const emit = defineEmits<{ 'account-settings': [] 'switch-account': [] 'log-out': [] 'sign-in': [] }>() defineSlots<{ default?(): unknown 'header-leading'?(): unknown 'header-middle'?(): unknown 'header-trailing'?(): unknown account?(): unknown 'account-menu'?(props: { close: () => void }): unknown 'sidebar-header'?(props: { collapsed: boolean }): unknown 'sidebar-footer'?(props: { collapsed: boolean; mobile: boolean }): unknown }>() /** Sidebar collapsed to icons (wide screens). Use v-model:collapsed to keep it, e.g. in localStorage. */ const collapsed = defineModel('collapsed', { default: false }) /** Mobile menu open. Optional: use v-model:menu-open to control it. */ const menuOpen = defineModel('menuOpen', { default: false }) ``` --- # Header > The bar across the top of every Keston app: menu button, app name, an optional middle area, actions and the account menu. The same in every app; each part can be overridden. - Import: `import { Header } from '@thekestongroup/ui'` - Group: Layout - Live example: https://ui.keston.co/storybook/?path=/story/components-layout-header--default - Source: https://github.com/thekestongroup/ui/blob/main/src/components/Header/Header.vue ## When to use - Once per app, at the top of every page, above the Sidebar and content. ## When not to use - Headings inside a page or panel (use a heading, or the Slideover's header). ## Anatomy | Part | Required | Notes | |---|---|---| | menu | No | Ghost square Button with a menu icon. Opens the Sidebar on small screens. | | leading | No | Before the title, e.g. a logo. | | title | No | App or area name. Bold, truncates. | | middle | No | Fills the space between title and trailing, e.g. breadcrumbs. | | trailing | No | Actions before the account menu, e.g. notifications. | | account | No | Round avatar (picture or initials) opening the account menu; Sign in button when signed out. Slot `account` replaces it. | | account menu | No | Panel under the avatar: organisation, large avatar, name, email, with a small basic Account settings button under them; then a row of basic buttons, Switch account (left) and Log out (right). Slot `account-menu` adds content above that row. | ## Properties | Property | Type | Default | Description | |---|---|---|---| | title | string | — | App or area name. | | menu | boolean | false | Show the menu button (emits `menu`). | | menuOpen | boolean | — | Whether what it opens is open (aria-expanded). | | menuControls | string | — | Id of what it opens (aria-controls). | | menuLabel | string | "Menu" | Accessible name of the menu button. | | user | { name, email?, organisation?, avatar? } | — | Signed-in user. Shows the account menu. | | userActions | ('account-settings' \| 'switch-account' \| 'log-out')[] | all three | Actions in the account menu (positions are fixed). Each emits an event of the same name. | | signIn | boolean | true | Show Sign in (emits `sign-in`) when there is no user. | | sticky | boolean | true | Stay at the top while the page scrolls. | | ui | { menu, title, middle, trailing } | — | Extra classes per part, e.g. `menu: 'lg:hidden'`. | ## Sizes | Size | Height | Padding (x / y) | Gap | Icon size | Text style | |---|---|---|---|---|---| | — | spacing 12 (48px) | spacing 4 / — | spacing 2 | 16px | title: base, bold | ## States | State | Appearance | Notes | |---|---|---| | Enabled | white, bottom border black 15% | | | Sticky | as enabled, pinned to the top | z-index above content, below dialogs | ## Behaviour - Menu button: press emits `menu`; the app opens the Sidebar (in a Slideover) itself. - Account menu: opens on click / tap / Enter below the avatar, aligned to its right edge (w-72). Focus moves into it; Escape or clicking outside closes it. Choosing an action closes it, then emits the event; the app does the navigation or sign-out. - The menu button's icon lines up with the header's padding (negative margin), like the × in Slideover. ## Accessibility - Rendered as `
` (banner landmark when at the top level of the page). - The title is not a heading; each page keeps its own `h1`. - Icon-only buttons in trailing need an `aria-label`. - The avatar button is named "Account: {name}", or "{initials}, account: {name}" when it shows initials, so its visible text is part of its name (voice control). Pictures and initials are hidden from assistive tech, as the name is in the label. ## Platform adaptation | Platform | Implementation | Differences | |---|---|---| | Web / desktop (Vue) | `Header.vue` | — | | iOS (SwiftUI) | — | Navigation bar / toolbar. | | Android (Compose) | — | TopAppBar. | ## Tokens used | Token | Used for | |---|---| | color white | background | | color black (15%) | bottom border, account menu divider | | color primary | avatar initials background | ## Do / Don't | Do | Don't | |---|---| | Keep trailing to a few icon buttons and one primary action. | Put full forms or labelled fields in the header. | ## API (from the source) The component's exact props, model, events, slots and types, with their documentation comments. ```ts // Header.vue export interface HeaderUser { name: string email?: string /** Organisation or school the account belongs to, shown at the top of the account menu. */ organisation?: string /** Picture URL. Initials are shown without one. */ avatar?: string } export type HeaderUserAction = 'account-settings' | 'switch-account' | 'log-out' /** * The bar across the top of every Keston app. It is the same in every app: * a menu button (to open the Sidebar on small screens), the app's name, an * optional middle area, actions, and the signed-in user's account menu on * the right (Account settings, Switch account, Log out). Each part can be * overridden with a slot. * * Slots: leading (before the title, e.g. a logo), title, default (the middle, * fills the space between title and trailing), trailing (actions before the * account menu), account (replaces the account menu, or the Sign in button), * account-menu (extra content in the account menu, above Switch account / * Log out: `{ close }`). */ export interface HeaderProps { /** Name of the app or area, shown next to the menu button. */ title?: string /** * Show the menu button, which emits `menu` when pressed. Hide it on wide * screens with `ui.menu`, e.g. `{ menu: 'lg:hidden' }`. */ menu?: boolean /** Whether what the menu button opens is open, for screen readers (aria-expanded). */ menuOpen?: boolean /** Id of what the menu button opens (aria-controls). */ menuControls?: string /** Accessible name of the menu button. */ menuLabel?: string /** * The signed-in user. Shows their picture or initials on the right, which * opens the account menu. Without it, a Sign in button is shown instead * (see `signIn`). */ user?: HeaderUser /** * Which actions the account menu offers. Each emits an event of the same * name. Account settings sits under the user's name; Switch account and Log * out share the bottom row (left and right). */ userActions?: HeaderUserAction[] /** Show a Sign in button (emits `sign-in`) when there is no `user`. */ signIn?: boolean /** * Stay at the top while the page scrolls (on by default). Turn off when the * header isn't inside the element that scrolls. */ sticky?: boolean class?: HTMLAttributes['class'] /** Extra classes for the parts. */ ui?: { menu?: HTMLAttributes['class'] title?: HTMLAttributes['class'] middle?: HTMLAttributes['class'] trailing?: HTMLAttributes['class'] userMenu?: HTMLAttributes['class'] } } const emit = defineEmits<{ menu: [] 'account-settings': [] 'switch-account': [] 'log-out': [] 'sign-in': [] }>() defineSlots<{ leading?(): unknown title?(): unknown default?(): unknown trailing?(): unknown account?(): unknown 'account-menu'?(props: { close: () => void }): unknown }>() ``` --- # PageHeader > The top of a page: breadcrumb, title, status, description and the page's main actions. - Import: `import { PageHeader } from '@thekestongroup/ui'` - Group: Layout - Live example: https://ui.keston.co/storybook/?path=/story/components-layout-pageheader--default - Source: https://github.com/thekestongroup/ui/blob/main/src/components/PageHeader/PageHeader.vue ## When to use Every page inside AppShell, as the first thing in the page. ## Anatomy | Part | Required | Notes | |---|---|---| | breadcrumb | No | Shown when there are two or more steps. Pass `sidebarTrail(items)`. | | title | Yes | The page's `h1`. | | badge | No | Slot after the title, e.g. a status Badge ("Draft"). | | description | No | A sentence under the title. | | actions | No | Slot on the right, e.g. Buttons, an AvatarGroup. Wraps under the title on narrow screens. | | below | No | Default slot, under everything, e.g. an alert or tabs. | ## Properties | Property | Type | Default | Description | |---|---|---|---| | title | string | — | Required. | | description | string | — | Slot `description` for links or formatting. | | breadcrumb | BreadcrumbItem[] | — | Top level first, the current page last. | | linkComponent | string \| Component | a | For breadcrumb steps with `to`. | | ui | { title, description, actions } | — | Extra classes per part. | ## Sizes | Part | Value | |---|---| | Title | 24px / 32px, bold, wraps | | Description | 14px / 20px, black 65%, up to 65 characters wide | | Breadcrumb to title | 8px | | Title to description | 4px | | Title to badge | 8px | | Title block to actions | 24px across, 12px when wrapped under | | Between actions | 8px | ## Behaviour | Situation | Result | |---|---| | Narrow screen | Actions wrap under the title | | Long title | Wraps onto more lines | | One breadcrumb step | Breadcrumb hidden (the title says it) | ## Accessibility | Part | Announced as | |---|---| | Title | `heading` level 1 | | Breadcrumb | `navigation` named "Breadcrumb" | | Container | `header` inside `main`, so not a banner landmark | ## Platform adaptation | Platform | Implementation | Differences | |---|---|---| | Web / desktop (Vue) | `PageHeader.vue` | — | | iOS (SwiftUI) | — | `navigationTitle` and toolbar items. | | Android (Compose) | — | Top app bar title and actions. | ## Tokens used | Token | Used for | |---|---| | color black | title | | color black (65%) | description | ## Do / Don't | Do | Don't | |---|---| | Put the page's one primary action here | Put several primary buttons side by side | | Use the badge for the page's status | Put long text in the badge | ## API (from the source) The component's exact props, model, events, slots and types, with their documentation comments. ```ts // PageHeader.vue /** * The top of a page: where it is (breadcrumb), what it is (the h1 title, with * an optional status badge and description), and its main actions on the * right. Actions wrap under the title on narrow screens. * * Slots: badge (after the title, e.g. a Badge), description (instead of the * prop, for links or formatting), actions (e.g. Buttons), default (anything * under, e.g. an alert or tabs). */ export interface PageHeaderProps { /** The page's name: its h1. */ title: string /** A sentence under the title. */ description?: string /** Where the page is. Pass `sidebarTrail(items)` to follow a nested Sidebar. */ breadcrumb?: BreadcrumbItem[] /** Component used for breadcrumb steps with `to`, e.g. RouterLink. */ linkComponent?: string | Component class?: HTMLAttributes['class'] /** Extra classes for the parts. */ ui?: { title?: HTMLAttributes['class'] description?: HTMLAttributes['class'] actions?: HTMLAttributes['class'] } } defineSlots<{ badge?(): unknown description?(): unknown actions?(): unknown default?(): unknown }>() ``` --- # Avatar > A person's picture, or their initials on a primary circle, optionally with a profile card on hover. - Import: `import { Avatar } from '@thekestongroup/ui'` - Group: Element - Live example: https://ui.keston.co/storybook/?path=/story/components-element-avatar--default - Source: https://github.com/thekestongroup/ui/blob/main/src/components/Avatar/Avatar.vue ## When to use | Use an Avatar for | Use instead | |---|---| | Showing who someone is beside their name, or in place of it | Several people together (AvatarGroup) | | A quick look at someone's details (`profile`) without leaving the page | Full details (link to their profile page) | ## Anatomy | Part | Required | Notes | |---|---|---| | circle | Yes | Primary fill, white bold initials, or the picture filling it. | | picture | No | `user.avatar`. Falls back to initials if it fails to load. | | profile card | No | With `profile`: a popover with organisation, a 48px avatar, name, role and email (a mailto link). | ## Properties | Property | Type | Default | Description | |---|---|---|---| | user | { name, avatar?, role?, email?, organisation? } | — | Required. Initials come from the first and last words of `name`. | | size | xs \| sm \| md \| lg \| xl | md | See Sizes. | | profile | boolean | false | Show the profile card on hover, click, tap or Enter. Makes the avatar a button. | | decorative | boolean | false | Hidden from screen readers, when the name is already said nearby. | ## Sizes | Size | Diameter | Initials | |---|---|---| | xs | 20px | 12px bold | | sm | 28px | 12px bold | | md | 36px | 14px bold | | lg | 48px | 16px bold | | xl | 64px | 20px bold | Profile card: 256px wide; avatar 48px; name 14px bold; role and email 14px, black 65%; organisation 12px bold uppercase, black 65%. ## States | State | Appearance | Notes | |---|---|---| | Picture | Picture, cropped to the circle | | | No picture / failed | Initials on primary | | | Focused (profile) | 2px focus ring `#007FC9`, 1px outside | Keyboard only | | Profile open | Card below, aligned to the start | Opens after 300ms hover, closes 150ms after leaving | ## Behaviour | Action | Result | |---|---| | Hover (profile) | Card opens after 300ms; stays while the pointer is on it | | Click, tap or Enter (profile) | Card opens; focus stays on the avatar | | Escape or click outside | Card closes | ## Accessibility | Case | Announced as | |---|---| | No profile | `img` named by the person's name | | Decorative | Hidden | | Profile, with initials | Button named "{initials}, {name}" (visible text first, for voice control) | | Profile, with a picture | Button named "{name}" | Initials on primary: white on `#023750` is 12.1:1. ## Platform adaptation | Platform | Implementation | Differences | |---|---|---| | Web / desktop (Vue) | `Avatar.vue` | — | | iOS (SwiftUI) | — | Circle-clipped `AsyncImage` with an initials fallback; profile in a `popover` (iPad) or sheet (iPhone). | | Android (Compose) | — | Circle-clipped image with an initials fallback; profile in a bottom sheet. | ## Tokens used | Token | Used for | |---|---| | color primary | circle background | | color white | initials | | color focus | focus ring | ## Do / Don't | Do | Don't | |---|---| | Use `decorative` when the name is beside it | Leave screen readers hearing the name twice | | Use `profile` where people want a quick look | Put actions (message, edit) only in the profile card | ## API (from the source) The component's exact props, model, events, slots and types, with their documentation comments. ```ts // Avatar.vue export interface AvatarUser { name: string /** Picture URL. Initials are shown without one, or if it fails to load. */ avatar?: string /** Job or role, e.g. "Head of Year 9". Shown in the profile. */ role?: string email?: string /** Organisation or school, shown in the profile. */ organisation?: string } /** * A person's picture, or their initials on a primary circle. With `profile`, * it becomes a button that shows a small profile card (name, role, email, * organisation) on hover, and on click, tap or Enter, so it works on touch * screens and from the keyboard. * * Several people overlapping: use AvatarGroup. */ export interface AvatarProps { user: AvatarUser /** 20 / 28 / 36 / 48 / 64px. */ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' /** Show a profile card on hover / press. Makes the avatar a button. */ profile?: boolean /** Hidden from screen readers, when the name is already said nearby (e.g. in a labelled button). */ decorative?: boolean class?: HTMLAttributes['class'] } ``` --- # AvatarGroup > Several people as overlapping avatars, each with a profile on hover, with the rest as "+N". - Import: `import { AvatarGroup } from '@thekestongroup/ui'` - Group: Element - Live example: https://ui.keston.co/storybook/?path=/story/components-element-avatargroup--default - Source: https://github.com/thekestongroup/ui/blob/main/src/components/AvatarGroup/AvatarGroup.vue ## When to use | Use an AvatarGroup for | Use instead | |---|---| | Who's involved: a class's teachers, who can sign off, who's editing | A list people need to read or act on (use a table or list) | ## Anatomy | Part | Required | Notes | |---|---|---| | avatars | Yes | Avatar each (with `profile` by default), overlapping, each with a white ring. | | counter | No | "+N" circle in surface grey, when there are more than `max`. Opens a list of the rest. | ## Properties | Property | Type | Default | Description | |---|---|---|---| | users | AvatarUser[] | — | Required. | | size | xs \| sm \| md \| lg \| xl | md | As Avatar. | | max | number | 4 | Avatars shown before "+N". If only one would be hidden, all are shown ("+1" saves no space). | | profile | boolean | true | Profile card on each avatar. | | label | string | "{n} people" | Name of the group for screen readers. | ## Sizes | Size | Avatar | Overlap | Ring | Counter text | |---|---|---|---|---| | xs | 20px | 2px | 1px | 12px | | sm | 28px | 4px | 2px | 12px | | md | 36px | 6px | 2px | 12px | | lg | 48px | 8px | 2px | 14px | | xl | 64px | 12px | 2px | 16px | Overlaps are as large as they can be without the ring reaching the initials. "+N" list: 224px wide, up to 288px tall then scrolls; a 20px avatar, name and role per person. ## Behaviour | Action | Result | |---|---| | Hover or focus an avatar | It comes to the front (its ring and focus outline show), and its profile opens | | Hover, click or Enter on "+N" | A list of everyone not shown | ## Accessibility | Part | Announced as | |---|---| | Group | `group` named by `label` ("7 people") | | Each avatar | As Avatar with `profile` | | Counter | Button named "{n} more: {names}" | ## Platform adaptation | Platform | Implementation | Differences | |---|---|---| | Web / desktop (Vue) | `AvatarGroup.vue` | — | | iOS (SwiftUI) | — | `HStack(spacing: -overlap)` of avatars; tap opens a sheet listing everyone. | | Android (Compose) | — | `Row` with negative spacing; tap opens a bottom sheet listing everyone. | ## Tokens used | Token | Used for | |---|---| | color white | rings | | color surface | counter background | | color primary | avatars | ## Do / Don't | Do | Don't | |---|---| | Give the group a label that says who they are ("People who can sign off") | Rely on "{n} people" where the context isn't clear | | Keep `max` small (3–5) | Show twenty overlapping avatars | ## API (from the source) The component's exact props, model, events, slots and types, with their documentation comments. ```ts // AvatarGroup.vue /** * Several people as overlapping avatars, e.g. a class's teachers or who's * been sent a message. Each shows its profile on hover (or press). Past * `max`, the rest are a "+N" circle that lists them. */ export interface AvatarGroupProps { users: AvatarUser[] size?: AvatarProps['size'] /** Avatars shown before "+N". */ max?: number /** Profile card on each avatar. */ profile?: boolean /** Name of the group for screen readers. Default "{n} people". */ label?: string class?: HTMLAttributes['class'] } ``` --- # Badge > A small label for a status, count or category. - Import: `import { Badge } from '@thekestongroup/ui'` - Group: Element - Live example: https://ui.keston.co/storybook/?path=/story/components-element-badge--default - Source: https://github.com/thekestongroup/ui/blob/main/src/components/Badge/Badge.vue ## When to use - Showing a status ("Paid", "Overdue"), a count (unread messages) or a category ("Year 9") next to the thing it describes. ## When not to use - Anything people can press (use Button). - Long messages or explanations (use text, or a callout). ## Anatomy | Part | Required | Notes | |---|---|---| | container | Yes | Pill shape with a 1px border. | | label | Yes | Short text or a number. | | leading | No | e.g. an icon. | | trailing | No | e.g. an icon. | ## Properties | Property | Type | Default | Description | |---|---|---|---| | color | basic \| primary \| red \| green \| blue \| yellow | basic | Colour, by meaning (see Variants). | | size | sm \| md \| lg | md | | ## Variants | Color | Use for | Example | |---|---|---| | basic | Neutral information, categories, counts | Year 9, 28 students | | primary | Emphasis | New | | green | Positive, done | Paid, Enrolled | | blue | Informational, ongoing | In progress | | yellow | Needs attention soon | Due soon | | red | A problem | Overdue | ## Sizes | Size | Height | Padding (x) | Gap | Icon size | Text style | |---|---|---|---|---|---| | sm | spacing 5 (20px) | spacing 1.5 | spacing 1 | 12px | xs, bold | | md | spacing 6 (24px) | spacing 2 | spacing 1 | 14px | xs, bold | | lg | spacing 7 (28px) | spacing 2.5 | spacing 1.5 | 16px | sm, bold | ## States Not interactive: one state only. ## Content guidelines - One or two words, sentence case ("Due soon", not "DUE SOON"). - Never wraps; keep it short rather than truncating. ## Accessibility - Plain text (``), read in place with the surrounding content. - Colour must not be the only signal: the text says what the colour means. - White text on every solid colour meets WCAG AA (4.5:1 or more), except yellow, which uses black text (12:1). ## Platform adaptation | Platform | Implementation | Differences | |---|---|---| | Web / desktop (Vue) | `Badge.vue` | — | | iOS (SwiftUI) | — | Capsule-shaped Text. | | Android (Compose) | — | Badge / AssistChip (non-clickable). | ## Tokens used | Token | Used for | |---|---| | color white, black | basic background, text | | color black (15%) | border | | color primary, green, blue, yellow, red | solid backgrounds (black text on yellow) | ## Do / Don't | Do | Don't | |---|---| | Pick the colour by meaning. | Use colours for decoration, or make them clickable. | ## API (from the source) The component's exact props, model, events, slots and types, with their documentation comments. ```ts // Badge.vue /** * A small label for a status, count or category, e.g. "Active", "3", "Year 9". * Not interactive: for something people can press, use a Button. * * Slots: default (the text), leading and trailing (e.g. an icon). */ export interface BadgeProps { /** * Colour, by meaning: * - basic: neutral information, e.g. a category or count * - primary: emphasis, e.g. "New" * - green: positive / done, e.g. "Paid" * - blue: informational, e.g. "In progress" * - yellow: needs attention, e.g. "Due soon" * - red: a problem, e.g. "Overdue" * * Colour alone doesn't carry meaning for everyone, so the text should say * it too. */ color?: 'basic' | 'primary' | 'red' | 'green' | 'blue' | 'yellow' size?: 'sm' | 'md' | 'lg' class?: HTMLAttributes['class'] } defineSlots<{ default?(): unknown leading?(): unknown trailing?(): unknown }>() ``` --- # Button > Starts an action: saving, cancelling, opening a panel, deleting. - Import: `import { Button } from '@thekestongroup/ui'` - Group: Element - Live example: https://ui.keston.co/storybook/?path=/story/components-element-button--default - Source: https://github.com/thekestongroup/ui/blob/main/src/components/Button/Button.vue ## When to use - Any action someone takes on the current page or record: Save, Cancel, Export, Delete. - Icon-only actions in toolbars and panel headers (`square`, usually `ghost`), e.g. × to close, ☰ to open the menu. ## When not to use - Going to another page: use a link (or `as-child` with a router link, so it is still a link). - Choosing between options: use a radio group, select or segmented control. - Turning a setting on or off: use a switch or checkbox. ## Anatomy | Part | Required | Notes | |---|---|---| | container | Yes | Rounded rectangle with a 1px border (transparent on ghost). | | leading | No | Icon before the label (slot `leading`). | | label | Yes, unless `square` | The text (default slot). | | trailing | No | Icon after the label (slot `trailing`), e.g. → on Next. | | status | No | Spinner, ✓ or × with optional text, shown in place of the content while `status` isn't idle. | | focus ring | — | Outline shown on keyboard focus. | ## Properties | Property | Type | Default | Description | |---|---|---|---| | variant | basic \| primary \| ghost \| danger | basic | What the button is for (see Variants). | | size | sm \| md \| lg | md | See Sizes. | | square | boolean | false | Icon only: as wide as it is tall. Needs `aria-label`. | | disabled | boolean | false | Can't be pressed or focused. | | type | button \| submit \| reset | button | Native button type. | | status | idle \| loading \| success \| error (v-model) | idle | Progress of the action. Success and error return to idle by themselves. | | loadingText / successText / errorText | string | — | Text next to the status icon. Icon only if not set. | | statusDuration | number (ms) | 2000 | How long success / error stay before returning to idle. | | as / asChild | string \| component / boolean | button / false | Render as another element, e.g. a link. | ## Variants | Variant | Background | Text | Border | Use for | Example | |---|---|---|---|---|---| | basic | white `#FAFAFA` | black `#050505` | black 15% | Most actions | Cancel, Export, Switch account | | primary | primary `#023750` | white `#FAFAFA` | black 15% | The main action, one per area | Save, Continue | | ghost | none | black `#050505` | transparent | Low emphasis, toolbars, icon buttons | ×, ☰, avatar | | danger | red `#D2042D` | white `#FAFAFA` | black 15% | Destructive actions | Delete | ## Sizes All sizes: 1px border, icons from Lucide at 2px stroke. Corners: 4px for small and medium (matching form fields), 6px for large. Heights are padding + one line of text + border, measured in the browser. | Size | Height | Padding (y / x) | Gap | Icon size | Square (w × h) | Radius | |---|---|---|---|---|---|---| | sm | 26px | 4px / 8px (spacing 1 / 2) | 4px (spacing 1) | 14px | 26 × 26px (spacing 6.5) | 4px (`rounded`) | | md | 38px | 8px / 16px (spacing 2 / 4) | 8px (spacing 2) | 16px | 38 × 38px (spacing 9.5) | 4px (`rounded`) | | lg | 50px | 12px / 24px (spacing 3 / 6) | 12px (spacing 3) | 20px | 50 × 50px (spacing 12.5) | 6px (`rounded-md`) | In a square button the icon is centred, so the space around it is equal on all four sides (5 / 10 / 14px inside the border). ## Typography Lato (bundled with the library), regular weight, sentence case, no letter spacing. Never wraps. | Size | Font size | Line height | Weight | Tailwind | |---|---|---|---|---| | sm | 12px | 16px | 400 (regular) | `text-xs` | | md | 14px | 20px | 400 (regular) | `text-sm` | | lg | 16px | 24px | 400 (regular) | `text-base` | Status text (loadingText, etc.) uses the same style as the label, with an 8px gap to its icon at every size. ## States | State | Appearance | Notes | |---|---|---| | Enabled | As in Variants. | | | Hovered | Filled variants: 90% brightness (white `#FAFAFA` → about `#E1E1E1`). Ghost: black 10% fill. | Web / desktop only. 150ms ease-in-out. | | Pressed | Filled variants: 80% brightness. Ghost: black 15% fill. | | | Focused | 2px outline in focus `#007FC9`, 1px outside the border. | Keyboard focus only (`:focus-visible`). | | Disabled | 40% opacity, not-allowed cursor, no hover / press. | | | Loading | Content fades out; a spinner (icon-sized) and optional text fade in. Cursor shows progress. | Stays focusable; clicks are blocked. | | Success | Green background, ✓ and optional text, for `statusDuration`. | Any variant. | | Error | Red background, × and optional text, for `statusDuration`. | Any variant. | ## Spacing and layout - Buttons in a row: 8px apart (`gap-2`). The main action goes last (right), e.g. Cancel then Save. - Minimum clear space around a group: 16px (spacing 4). - Minimum touch target: md is 38px tall. On touch-first screens prefer lg (50px), or give sm / md buttons extra space around them so the tap area reaches 44px. - Ghost icon buttons at the edge of a bar or panel use a negative margin so their icon lines up with the bar's padding (see Header, Slideover). ## Behaviour - Keyboard: Tab to focus; Enter or Space presses it. - Pointer / touch: press on release, as native buttons do. - Motion: colours change over 150ms (ease-in-out). Returning from success / error takes 300ms, and the width animates over 300ms (ease-out) when the status text changes its size. Icons fade and scale; with reduced motion they only fade and the width doesn't animate. ## Content guidelines - A verb, or verb + noun: "Save", "Export students". Sentence case. - One to three words. Don't truncate; shorten the wording instead. - Status text says what is happening or happened: "Saving…", "Saved", "Couldn't save". ## Accessibility - Native `