← Back to the design system

Legacy comparison sheet · mirrors the old style-guide site one-to-one

navy-500 · navy lighter #466285
navy-600 · navy light #2e4056
navy-700 · navy #233142
navy-900 · navy dark #18222e
blue-300 · blue lighter #52e2ff
blue-400 · blue light #0bd5ff
blue-600 · blue #00c3eb
blue-700 · blue dark #00aacc
red-200 · red lighter #fdbbbb
red-400 · red light #fa7777
red-600 · red #f95959
red-700 · red dark #f83b3b
green-300 · green lighter #66f0bf
green-400 · green light #25eaa4
green-600 · green #15db95
green-700 · green dark #12bf82
yellow-200 · gold lighter #fee7b2
yellow-300 · gold light #fdd16c
yellow-400 · gold #fcc74e
yellow-500 · gold dark #fbbd30
grey-200 #d2d5d8
grey-300 #abb0b7
grey-400 #9ba1a9
grey-600 (text) #6b737c
grey-50 · light-grey #f5f8f9
grey-100 · light-grey dark #e2ebee

60 header--lg navy

60 header--lg bold

60 header--lg uppercase

60 header--lg italic

60 header--lg bold italic green

48 header header--md (default)

36 header header--sm blue

30 sub-header sub-header--lg

24 subheader

18 sub-header sub-header--sm

14 sm paragraph Lorem ipsum dolor sit amet consectetur adipisicing elit. Ab quos corporis repellendus ipsa aperiam eveniet recusandae tempora dolor quasi debitis, adipisci suscipit laborum deleniti eaque quaerat qui consequatur, officiis voluptas.

16 md paragraph Lorem ipsum dolor sit amet consectetur adipisicing elit. Ab quos corporis repellendus ipsa aperiam eveniet recusandae tempora dolor quasi debitis, adipisci suscipit laborum deleniti eaque quaerat qui consequatur, officiis voluptas.

20 lg paragraph Lorem ipsum dolor sit amet consectetur adipisicing elit. Ab quos corporis repellendus ipsa aperiam eveniet recusandae tempora dolor quasi debitis, adipisci suscipit laborum deleniti eaque quaerat qui consequatur, officiis voluptas.

16md For 50 years, WWF has been protecting the future of nature. The world's leading conservation organization, WWF works in 100 countries and is supported by 1.2 million members in the United States and close to 5 million globally.
20lg For 50 years, WWF has been protecting the future of nature. The world's leading conservation organization, WWF works in 100 countries and is supported by 1.2 million members in the United States and close to 5 million globally.
For 50 years, WWF has been protecting the future of nature. The world's leading conservation organization, WWF works in 100 countries and is supported by 1.2 million members in the United States and close to 5 million globally.

20 lg paragraph Lorem ipsum dolor sit amet consectetur adipisicing elit. Ab quos corporis repellendus ipsa aperiam eveniet recusandae tempora dolor quasi debitis, adipisci suscipit laborum deleniti eaque quaerat qui consequatur, officiis voluptas.

12 bonusway.fi16 bonusway.fi20 bonusway.fi20 bold italic bonusway.fiinternal link (locale-aware)underline="hover" (default) — alleviivaus vain kursorin allaunderline="always" — leipätekstin sisälläunderline="none" — kortin tai navigaation sisällä
12 caption — helper texts, timestamps, secondary labels12 metadata
link text

Need some help?

Lorem ipsum, dolor sit amet consectetur adipisicing elit.

Need some help?

Lorem ipsum, dolor sit amet consectetur adipisicing elit.

Need some help?

Lorem ipsum, dolor sit amet consectetur adipisicing elit.

Need some help?

Lorem ipsum, dolor sit amet consectetur adipisicing elit.

Success!

Here are some success text, some success text.

Warning!

Here are some warning text, some warning text.

Enter correct email format

Your data is private. Learn more

Your data is private. Learn more

Your data is private.

The copy adornment is a real IconButton.

Controls stay owned by the caller.

Successful submits: 0

Något gick fel

PayPal

Want to withdraw bonuses via PayPal?

€5.00

PayPal

Want to withdraw bonuses via PayPal?

€5.00

Vähintään 20 merkkiä.

3 / 5

Vahvista toiminto

Haluatko varmasti poistaa tämän? Toimintoa ei voi perua.

Card

Default 20px padding.

Card shadow

Guide drop shadow.

Interactive

Hover lift, like store cards.

padding="none" media card

tone="blue"

Tinted ground, Badge's soft blue.

tone="green"

A bg-* in className cannot do this — use the prop.

tone="yellow"

The followed-shops feed card. There is deliberately no grey tone: grey-50 is within a hair of the app's own ground, so it would read as a card that failed to paint.

tone="lavender"

Production's own #e6e6fc refer-a-friend ground. The one tone that is not a -50 step of a brand scale, and the one that cannot carry grey-600 secondary text: 3.91:1, under AA.

MasonryGrid — natural heights, no stretch

Cards keep their own height and pack upwards, unlike Grid, which stretches every cell in a row to the tallest. Same 1 / 2 / 2 / 3 columns as the home feed. DOM order is source order, so reading and focus order are unchanged.

Card 1

120px of filler

Card 2

60px of filler

Card 3

200px of filler

Card 4

90px of filler

Card 5

150px of filler

Card 6

70px of filler
MuotiElektroniikkaVahvistettuBoostUusiOutline
BonuswayBroken Image

Sähköposti-välilehden sisältö.

size="sm" (14px)

size="lg" (20px)

Mikä on Bonusway?

Bonusway maksaa sinulle bonusta verkko-ostoksistasi.

Miten bonukset maksetaan?

Voit nostaa bonukset esimerkiksi PayPalin kautta.

Maksaako palvelu mitään?

Ei maksa — palvelu on käyttäjälle ilmainen.

Ostos — Verkkokauppa Oy

Rivin tiedot aukeavat samalle reunalle kuin otsikko.

Tilitys — PayPal

Kortti on koko rivin kokoinen painike.

KauppaBonusTila
Zalando5,5 %Vahvistettu
Verkkokauppa.com1,2 %Odottaa

Radisson Hotels

Tietoa kaupasta

Bonusway maksaa sinulle bonusta verkko-ostoksistasi.
Yksittäiset rivinvaihdot säilyvät kuten legacy-CMS:n teksteissä.

Component reference

Generated from src/components/ui — names and prop types come straight from the source files.
Accordion, AccordionItemsrc/components/ui/Accordion.tsx
interface AccordionProps extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
}

/** Mirrors Card's scale, because the two are used against each other. */
type AccordionPadding = 'none' | 'md';

type AccordionSize = 'sm' | 'md';

type AccordionWeight = 'normal' | 'bold';

interface AccordionItemProps {
  title: ReactNode;
  defaultOpen?: boolean;
  /**
   * Horizontal inset for the row's own content, matching `Card`'s `md`.
   *
   * Default `none` suits an accordion sitting among other content inside a
   * padded Card — it shares that padding and lines up with its neighbours.
   * Use `md` for an accordion used as a LIST that fills a `padding="none"`
   * Card: the rules and any row background then span the card's full width,
   * while the content still sits on a 20px edge. Padding it inside an already
   * padded Card insets it twice.
   */
  padding?: AccordionPadding;
  /** Row height. Defaults to the 60px FAQ scale. */
  size?: AccordionSize;
  /**
   * Title weight. Bold by default — an accordion is usually a heading over its
   * content. `normal` is for a row that is one entry in a list rather than a
   * heading, where bold would instead be free to mean something (the nav
   * drawer bolds the section the visitor is currently inside).
   */
  weight?: AccordionWeight;
  /**
   * Draw the hairline rule under the item. On by default, which is what a
   * multi-item accordion needs. Turn it OFF for a single item filling its own
   * Card — the card's border already closes the row, and the rule would just
   * hang across the bottom of it.
   */
  divider?: boolean;
  className?: string;
  children: ReactNode;
}
Alertsrc/components/ui/Alert.tsx
type AlertVariant = 'success' | 'error' | 'warning';

interface AlertProps {
  variant: AlertVariant;
  title: string;
  /** Optional body under the bold title — omit for one-line notifications. */
  children?: ReactNode;
}
Avatarsrc/components/ui/Avatar.tsx
type AvatarSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl';

interface AvatarProps {
  /** Person's name; drives the initials fallback and the image alt text. */
  name: string;
  src?: string;
  size?: AvatarSize;
  /**
   * How the image fills the circle: 'cover' (default) crops to fill — right
   * for photos/faces; 'contain' letterboxes with a little padding — for
   * rectangular marks like store logos.
   */
  fit?: 'cover' | 'contain';
  /**
   * The no-photo fallback: 'initials' (default) derives up to two letters
   * from `name` — used for store logos and reviewer avatars, where the
   * letter still identifies the thing. 'icon' shows a generic glyph instead —
   * for the current user's own avatar, where showing their own initials back
   * at them adds nothing.
   */
  fallback?: 'initials' | 'icon';
  className?: string;
}
Badgesrc/components/ui/Badge.tsx
type BadgeColor = 'navy' | 'blue' | 'red' | 'green' | 'gold' | 'grey';

type BadgeVariant = 'filled' | 'soft' | 'outline';

interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  color?: BadgeColor;
  variant?: BadgeVariant;
  /** Optional leading icon, e.g. <Icon name="tag" size="sm" />. */
  icon?: ReactNode;
  children: ReactNode;
}
BottomBarsrc/components/ui/BottomBar.tsx
interface BottomBarProps {
  /** Accessible name of the region — the bar's own heading text. */
  title: string;
  children: ReactNode;
  className?: string;
}
Boxsrc/components/ui/Box.tsx
interface BoxProps {
  /** Left column content (e.g. a payment-method logo). Images render at 60% width, centered. */
  media: ReactNode;
  /** Adds the green border + floating checkmark badge of the style guide's `.box.selected`. */
  selected?: boolean;
  onClick?: () => void;
  className?: string;
  children: ReactNode;
}
Breadcrumbsrc/components/ui/Breadcrumb.tsx
type InternalHref = ComponentProps<typeof Link>['href'];

export interface BreadcrumbItem {
  label: string;
  /** Omit on the last (current-page) item. */
  href?: InternalHref;
}

interface BreadcrumbProps {
  items: BreadcrumbItem[];
  className?: string;
}
Buttonsrc/components/ui/Button.tsx
// 'facebook' is the social-login brand color (#1877F2) — production's
// Facebook CTAs use the real brand blue, not our cyan. This is Facebook's
// CURRENT blue; don't confuse it with globals.css's
// `--color-facebook-legacy-600` (#3b5998), the 2011 blue that exists solely
// to match production's round share buttons on hover.
export type ButtonColor = 'blue' | 'red' | 'green' | 'navy' | 'facebook';

export type ButtonSize = 'sm' | 'md' | 'lg';

type InternalHref = ComponentProps<typeof Link>['href'];

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  color?: ButtonColor;
  size?: ButtonSize;
  /** Border + text only, white background — matches the style guide's "outline" variant. */
  outline?: boolean;
  /**
   * Label only — no border or fill, a shade darker than the filled color,
   * light-grey wash on hover. For low-emphasis actions (e.g. repeated card
   * CTAs) where even an outline is too much chrome. Wins over `outline`.
   */
  text?: boolean;
  /** Adds the drop shadow used on the style guide's "shadow" button examples. */
  shadow?: boolean;
  /**
   * Renders the button as a link: internal routes go through the locale-aware
   * next-intl Link (prefetch + client navigation + translated pathnames),
   * external URLs (https:, //, mailto:, tel:) open in a new tab. Combined
   * with `disabled`, renders an inert element instead of a link.
   */
  href?: InternalHref | string;
  /**
   * Only meaningful with an internal `href`: opens the route in a new tab
   * (`_blank`) instead of navigating this one. External hrefs already open
   * in a new tab regardless. Sets `rel="noopener"` alongside `_blank` so the
   * new tab has no `window.opener` back into this one — not a security
   * necessity on a same-origin link, but free, and it keeps the new tab from
   * poking the bonusway tab by accident. Use it for a route whose JOB is to
   * leave the site (the waiting page), never as a default for internal links.
   */
  target?: '_blank' | '_self';
}
Cardsrc/components/ui/Card.tsx
type CardPadding = 'none' | 'sm' | 'md';

/**
 * A card's ground. Chosen here rather than passed in via `className` because a
 * same-property override from className is unreliable — Tailwind resolves
 * `bg-blue-50` against this component's own `bg-white` by stylesheet order, so
 * the tint silently lost (the home feed's extension card was white for exactly
 * that reason). Same trap Avatar documents for its `fit` grounds.
 *
 * The tints are the soft steps Badge already uses, so a tinted card and a soft
 * badge read as the same language.
 */
export type CardTone = 'white' | 'blue' | 'green' | 'yellow' | 'lavender';

interface CardProps extends HTMLAttributes<HTMLElement> {
  as?: ElementType;
  padding?: CardPadding;
  /** The guide's standard drop shadow. */
  shadow?: boolean;
  /** Background tint. Defaults to white. */
  tone?: CardTone;
  /** Hover lift + shadow for clickable cards (store cards, deal cards, …). */
  interactive?: boolean;
  children: ReactNode;
}
Carouselsrc/components/ui/Carousel.tsx
type InternalHref = ComponentProps<typeof Link>['href'];

/**
 * Slide link: an internal route (string key or typed {pathname, params}
 * object for parameterized routes, TextLink-style) or an external URL.
 *
 * `params` accepts a `string[]` value, not just `string`, because a detail
 * page's slug segment is a catch-all (`@/lib/detailHref`'s `storeHref` and
 * friends return `rest: string[]`) — the actual `<Link>` this renders is
 * cast to its real typed href below regardless, so this is purely about
 * letting a slide built from one of those helpers type-check here too.
 */
export type CarouselHref =
  | string
  | { pathname: string; params?: Record<string, string | string[]> };

export interface CarouselSlide {
  src: string;
  alt: string;
  href: CarouselHref;
}

interface CarouselProps {
  slides: CarouselSlide[];
  /** Milliseconds between automatic advances; 0 disables autoplay. */
  autoPlayInterval?: number;
  /**
   * Reserved width/height ratio (CSS aspect-ratio), so the page doesn't
   * shift when the images arrive. Defaults to the live banner size, 990×290.
   */
  aspectRatio?: string;
  className?: string;
  'aria-label'?: string;
  previousLabel?: string;
  nextLabel?: string;
  /** Indicator label; `{n}` is replaced with the 1-based slide number. */
  slideLabel?: string;
  pauseLabel?: string;
  resumeLabel?: string;
}
Checkboxsrc/components/ui/Checkbox.tsx
// Same peer-checked approach as Radio.tsx, matching the style guide's
// `.checkbox`: 16px rounded square, blue fill + icomoon checkmark when
// checked, 30px total label offset (16px box + 14px gap), 16px label text.
type CheckboxProps = InputHTMLAttributes<HTMLInputElement> & {
  /** Plain text or rich content (e.g. a sentence with TextLinks in it). */
  label: React.ReactNode;
  /** Validation message shown in red below the label, matching Input's `error`. */
  error?: string;
  /**
   * Marks the box as required: an asterisk after the label and `aria-required`
   * on the input. Same contract as Field's `required` — it INTERCEPTS the
   * native attribute rather than passing it on, because that gate fires before
   * the form's own submit handler and pre-empts the inline `error` below.
   * A checkbox can't use Field (it carries its own label), so it marks itself.
   */
  required?: boolean;
};
Containersrc/components/ui/Container.tsx
type ContainerSize = 'md' | 'lg';

interface ContainerProps extends HTMLAttributes<HTMLElement> {
  as?: ElementType;
  size?: ContainerSize;
  children: ReactNode;
}
Dividersrc/components/ui/Divider.tsx
interface DividerProps {
  /** Optional centered label, e.g. <Divider>tai</Divider> between login options. */
  children?: ReactNode;
  className?: string;
}
Drawersrc/components/ui/Drawer.tsx
interface DrawerProps {
  open: boolean;
  onClose: () => void;
  /** Accessible name for the panel — it has no visible heading of its own. */
  label: string;
  /** Accessible name for the close button, e.g. "Sulje valikko". */
  closeLabel: string;
  /** Which edge the panel is attached to. */
  side?: keyof typeof SIDE;
  className?: string;
  children: ReactNode;
}
Dropdownsrc/components/ui/Dropdown.tsx
export interface DropdownOption {
  value: string;
  label: string;
}

type DropdownSize = 'sm' | 'md';

interface DropdownProps {
  options: DropdownOption[];
  value?: string;
  onChange?: (value: string) => void;
  placeholder?: string;
  /** Renders the style guide's "searchable" variant: an input that filters the menu while typing. */
  searchable?: boolean;
  disabled?: boolean;
  /** md (default) is the guide's 60px field; sm the 40px compact variant, matching Input's. */
  size?: DropdownSize;
  className?: string;
  /**
   * Applied to the field element (the input when searchable, the button
   * otherwise) so `Field` can wire its label to it — Field clones its child
   * with an `id` and `aria-describedby`, which a component that doesn't accept
   * them silently drops, leaving a label that points at nothing.
   */
  id?: string;
  'aria-label'?: string;
  'aria-describedby'?: string;
}
RequiredMark, Fieldsrc/components/ui/Field.tsx
interface FieldProps {
  label: string;
  /** Helper text below the control, styled like the guide's `.input-metadata`. */
  help?: ReactNode;
  /**
   * Marks the control as required: an asterisk after the label, plus
   * `aria-required` on the control itself.
   *
   * Deliberately NOT the native `required` attribute. That gate fires before a
   * form's own submit handler, which both pre-empts the inline errors this
   * design system uses and lets whitespace-only text through — see the review
   * form, where it stopped the rating error from ever appearing. So this is the
   * indicator and the announcement; the validation stays with the form.
   *
   * A control may still carry the native attribute alongside this prop, and
   * several do: where the submit path validates nothing (login, password reset
   * request, the SMS code), the browser's gate is the only thing standing
   * between an empty box and a misleading answer. Those sites say so at the
   * call. Drop the attribute once the handler reports the field itself.
   */
  required?: boolean;
  /**
   * Label size to pair with the control's: md (default) is the guide's 16px
   * label over a 60px field; sm drops it to 14px for a `size="sm"` control,
   * so a compact filter bar shrinks as a whole rather than control-only.
   */
  size?: 'sm' | 'md';
  className?: string;
  /** Exactly one form control (Input, Select, Dropdown, …). */
  children: ReactElement<{
    id?: string;
    'aria-describedby'?: string;
    'aria-required'?: boolean;
  }>;
}
FileUploadsrc/components/ui/FileUpload.tsx
export type FileUploadStatus = 'pending' | 'uploading' | 'done' | 'error';

/**
 * One file the page is tracking through the receipt-upload lifecycle.
 *
 * FileUpload is a fully controlled, network-free component: it never fetches
 * and it never owns this list. `onAdd` hands the page the File objects a
 * user picked (already passed this component's own add-time validation); the
 * page assigns the id, starts it at `status: 'pending'`, and flips it to
 * 'uploading' / 'done' / 'error' as its own server action progresses.
 *
 * There is deliberately no percentage-progress field. A fetch-based Next
 * server action cannot stream upload progress the way XHR's progress event
 * can, so a number here would either be fake or stuck — the four discrete
 * statuses are the honest version of "upload progress" for this transport.
 */
export interface FileUploadItem {
  id: string;
  file: File;
  status: FileUploadStatus;
  /** Shown under the row when status is 'error' — e.g. the server action's rejection reason. */
  error?: string;
}

export interface FileUploadStatusLabels {
  pending: string;
  uploading: string;
  done: string;
  error: string;
}

export interface FileUploadMessages extends FileValidationMessages {
  /** Shown per excess file when a pick/drop would push the total past `maxFiles`; the excess files are never added. */
  tooMany: string;
}

export interface FileUploadProps {
  /** The controlled list — see FileUploadItem. */
  files: FileUploadItem[];
  /** Files that passed this component's own add-time checks (type, size, and the maxFiles count). The page assigns ids and appends them to `files`. */
  onAdd: (files: File[]) => void;
  onRemove: (id: string) => void;
  /** Accepted MIME types. Also becomes the native input's `accept` attribute. */
  accept: string[];
  maxSizeBytes: number;
  /** Caps the total across `files.length` plus anything newly picked; the excess is rejected with `messages.tooMany`. */
  maxFiles?: number;
  disabled?: boolean;
  /**
   * The dropzone's visible text and its accessible name. FileUpload reads no
   * i18n of its own — the missing-bonus-claim plan's "copy via props"
   * decision — so this (and `help`, `error`, `statusLabels`, `messages`
   * below) is how the page supplies translated copy for the ×11 locales.
   * Like Checkbox, FileUpload carries its own label rather than going inside
   * a Field: the dropzone IS the labelled control.
   */
  label: string;
  /**
   * Helper text under the dropzone — typically the accepted types and size,
   * e.g. "PDF, JPG, PNG or GIF, up to 4 MB." Hidden while `error` is set or a
   * pick was rejected: the problem replaces the guidance rather than stacking
   * beneath it, since the two otherwise repeat each other almost verbatim.
   */
  help?: string;
  /** A form-level error, e.g. "At least one receipt is required." Rendered below the dropzone and linked via aria-describedby, the same contract Input's `error` follows. */
  error?: string;
  /**
   * Per-status text next to each row. The defaults are English fallbacks —
   * the same relationship Modal's hardcoded "Close" and Toast's "Dismiss"
   * have to the rest of this app's chrome — so the component and its
   * showcase entry work without translations wired up. The real page passes
   * localized copy here, same as `label`/`help`.
   */
  statusLabels?: Partial<FileUploadStatusLabels>;
  /** Override the add-time rejection wording (type/size/count). Same English-fallback relationship as `statusLabels`. */
  messages?: Partial<FileUploadMessages>;
  /**
   * A field-level name rendered ABOVE the dropzone, styled like `Field`'s
   * label — "what this field is", as distinct from `label`, which is the
   * instruction inside the box ("Choose a file"). Optional: a dropzone whose
   * own text already names the thing needs no title.
   *
   * This exists because FileUpload carries its own label instead of going
   * inside a `Field` (the dropzone is a `<button>`, and a `<label htmlFor>`
   * does not associate with one), so without it a file field is the only
   * control in the system that cannot say both. When set, it joins the
   * dropzone's accessible name ahead of `label`, so a screen reader announces
   * "<title> <label>" rather than dropping one of them.
   */
  title?: string;
  /**
   * Renders `Field`'s asterisk beside `title`. Needs `title` to sit next to —
   * an asterisk with nothing to qualify says nothing.
   *
   * No `aria-required` goes on the dropzone, for the same reason there is no
   * `aria-invalid` on it (see the button below): ARIA does not define either
   * for `role="button"`, and jsx-a11y flags them. The asterisk is the visual
   * signal and `error` carries the message through `aria-describedby`, which
   * IS defined for any role. FileUpload never enforces the rule either way —
   * it has no submit to block; the caller checks it.
   */
  required?: boolean;
  id?: string;
  className?: string;
}

interface Rejection {
  name: string;
  message: string;
}
Formsrc/components/ui/Form.tsx
/**
 * What a form's submit handler reports back. Server actions used with `Form`
 * should return this shape.
 *
 * The split between the two error fields is the one ui/DESIGN.md mandates:
 * "Form validation errors should appear inline. All other error messages
 * should appear as a modal dialog."
 */
export interface FormResult {
  /**
   * Validation errors keyed by field name — rendered *inline* on the control
   * itself, via the `fieldErrors` the render prop hands back.
   */
  fieldErrors?: Record<string, string>;
  /**
   * A form-level failure the user can act on right here — wrong password,
   * an expired reset link, a captcha to retry. Rendered INLINE as an error
   * Alert above the submit button.
   *
   * The distinction from `error` is what the two are telling the user. This one
   * says "this attempt didn't go through, adjust and try again", and belongs on
   * the page next to the thing they'd adjust. `error` is a modal titled
   * "something went wrong", which is the right words only for a genuine fault
   * — and using it for a mistyped password both misdescribes the problem and
   * costs a dismissing click before the retry.
   */
  formError?: string;
  /** A genuine fault, nothing the user did. Rendered as a modal dialog. */
  error?: string;
  /** Set when a V3 captcha attempt failed verification: show the V2 challenge instead. */
  requiresCaptchaFallback?: boolean;
}

/** State the render prop needs to draw its controls. */
export interface FormState {
  /** Field errors from the last submit; pass into each control's `error` prop. */
  fieldErrors: Record<string, string>;
  isPending: boolean;
}

interface FormBaseProps {
  submitLabel: string;
  /** Submit-button label while the submission is in flight. */
  pendingLabel: string;
  /** The form's controls. */
  children: ReactNode | ((state: FormState) => ReactNode);
  /** Rendered below the submit button, e.g. a "already have an account?" line. */
  footer?: ReactNode;
  /**
   * A second control sharing the submit button's row — a "back" button on a
   * multi-leg form, say. Both size to their own label, as the withdrawal
   * flow's footer does, and the row wraps when the two no longer fit side by
   * side. The submit button only takes the full width when it stands alone.
   */
  secondaryAction?: ReactNode;
  /**
   * Where `secondaryAction` sits relative to the submit button.
   *
   * `'inline'` (the default) keeps the two adjacent with the secondary first
   * — a Back button belongs beside the action it steps away from, as
   * PhoneForm's does. `'opposite'` pushes them to opposite ends of the row,
   * submit first: the arrangement for a control that is not part of the same
   * progression, like a "discard this whole attempt" link, where sitting
   * flush against the primary button would invite a mis-click.
   */
  secondaryPlacement?: 'inline' | 'opposite';
  /**
   * How wide the submit button is when it stands alone. `'full'` (the
   * default) is the app's form look: one button spanning the card. `'auto'`
   * sizes it to its own label, for a form whose legs otherwise disagree with
   * themselves — the phone form's first leg would jump from a full-width
   * button to a narrow one as soon as the code field appears beside a Back
   * button. Ignored when `secondaryAction` is set: two buttons sharing a row
   * are always label-width.
   */
  submitWidth?: 'full' | 'auto';
  className?: string;
}

/**
 * Either the form runs the captcha flow and its onSubmit receives the token,
 * or it opts out entirely and onSubmit takes no argument — never a caller
 * having to handle a token that might be missing.
 */
type FormProps = FormBaseProps &
  (
    | {
        /**
         * reCAPTCHA action name, e.g. "login" / "signup" / "password_reset".
         * Must match the action the server verifies the token against.
         */
        captchaAction: string;
        /** Called with a fresh captcha token. Return a FormResult, or nothing on success. */
        onSubmit: (captcha: CaptchaToken) => Promise<FormResult | void>;
      }
Gridsrc/components/ui/Grid.tsx
type Cols = 1 | 2 | 3 | 4 | 5 | 6 | 12;

interface GridProps extends HTMLAttributes<HTMLElement> {
  as?: ElementType;
  /** Columns from the smallest screens up; sm/md/lg/xl override at those breakpoints. */
  cols?: Cols;
  sm?: Cols;
  md?: Cols;
  lg?: Cols;
  xl?: Cols;
  /** Space between cells. Defaults to the 20px gutter. */
  gap?: LayoutGap;
  children: ReactNode;
}
Iconsrc/components/ui/Icon.tsx
export type IconName = (typeof ICON_NAMES)[number];

type IconSize = 'sm' | 'md' | 'lg';

interface IconProps extends HTMLAttributes<HTMLSpanElement> {
  name: IconName;
  /** sm 16px / md 20px (default) / lg 25px — the style guide's icon--* sizes. */
  size?: IconSize;
}
IconButtonsrc/components/ui/IconButton.tsx
export type IconButtonSize = 'sm' | 'md' | 'lg';

export type IconButtonColor = 'navy' | 'blue' | 'red' | 'green' | 'gold';

type InternalHref = ComponentProps<typeof Link>['href'];

// A chromeless button for a single icon (e.g. <IconButton aria-label="Remove">
// <Icon name="cross" /></IconButton>): transparent until hovered, then a
// light-grey wash. An accessible name is required since the icon itself is
// aria-hidden.
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  size?: IconButtonSize;
  color?: IconButtonColor;
  /**
   * 'square' (default) is the fixed icon-sized hit target. 'auto' keeps the
   * same height but sizes width to content, for children wider than one
   * icon (e.g. an avatar plus a dropdown chevron).
   */
  shape?: 'square' | 'auto';
  /**
   * Hairline border with a translucent white base that turns solid on hover —
   * for icon buttons floating over imagery (card corner actions), where the
   * chromeless default disappears into busy photos.
   */
  outline?: boolean;
  /**
   * Drops the component's own background, hover wash, text colour AND corner
   * radius, leaving only the box — size, transition and the focus ring. For the one
   * case a className cannot serve: a caller that needs its OWN hover
   * background (the brand-filled social share buttons, which fill with
   * Facebook blue on hover). A caller's `hover:bg-*` and this component's
   * `hover:bg-white` are the same property at the same specificity, so the
   * winner is stylesheet order rather than source order — the same trap
   * Card.tsx documents for its tones, which is why the class has to be absent
   * rather than merely overridden. The caller then owns border, ground, glyph
   * colour and radius outright — the radius for the same reason, since
   * `rounded-full` on a caller would be racing this component's own
   * `rounded-[3px]`, and a social button that loses that race is a rounded
   * square rather than a circle.
   */
  unstyled?: boolean;
  /**
   * Renders the icon button as a link, like Button's `href`: internal routes
   * go through the locale-aware next-intl Link, external URLs (https:, //,
   * mailto:, tel:) open in a new tab. Combined with `disabled`, renders an
   * inert button instead of a link.
   */
  href?: InternalHref | string;
  /**
   * Only meaningful with an internal `href`: opens the route in a new tab
   * (`_blank`) instead of navigating this one. External hrefs already open
   * in a new tab regardless. Sets `rel="noopener"` alongside `_blank`. Use it
   * for a route whose JOB is to leave the site (the waiting page), never as a
   * default for internal links. Same contract as Button's `target`.
   */
  target?: '_blank' | '_self';
  'aria-label': string;
}
IconTogglesrc/components/ui/IconToggle.tsx
// An IconButton that acts as an on/off toggle and swaps to a filled glyph
// when pressed — made for the icon font's outline/filled pairs, e.g.
// <IconToggle aria-label="Favorite" icon="heart" activeIcon="heart-active"
// color="red" />. Works uncontrolled (defaultPressed) or controlled
// (pressed + onPressedChange). Exposes state via aria-pressed.
interface IconToggleProps
  extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'color' | 'onToggle'> {
  icon: IconName;
  /** The filled variant shown while pressed (defaults to `icon` if the font has no pair). */
  activeIcon?: IconName;
  /** Fill color while pressed; unpressed stays navy like a plain IconButton. */
  color?: IconButtonColor;
  size?: IconButtonSize;
  pressed?: boolean;
  defaultPressed?: boolean;
  onPressedChange?: (pressed: boolean) => void;
  'aria-label': string;
}
Inputsrc/components/ui/Input.tsx
type InputSize = 'sm' | 'md';

interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
  size?: InputSize;
  /** Shown below the field in red, switches the border to red and reveals the cross icon, matching the style guide's `.input__error`. */
  error?: string;
  /**
   * Adornment inside the field at the right edge — a plain <Icon .../> or an
   * interactive <IconButton size="sm" .../> (30px, fits the 60px field). With
   * an IconButton, widen the text inset via className="pr-[60px]" so long
   * values don't run underneath it. Hidden while `error` shows its cross.
   */
  icon?: ReactNode;
}
MasonryGridsrc/components/ui/MasonryGrid.tsx
interface MasonryGridProps {
  /**
   * The cards, in feed order. Arrays are flattened (so `{firstPage}{appended}`
   * works), but a single `<>…</>` around them all counts as ONE child and
   * would get one wrapper — pass the arrays, not a Fragment.
   */
  children: React.ReactNode;
  className?: string;
}
Menu, MenuItemsrc/components/ui/Menu.tsx
type InternalHref = ComponentProps<typeof Link>['href'];

interface MenuProps {
  /** The element that opens the menu (e.g. an IconButton); gets onClick + aria wiring injected. */
  trigger: ReactElement<{
    onClick?: (e: MouseEvent) => void;
    'aria-haspopup'?: string;
    'aria-expanded'?: boolean;
  }>;
  /** Which edge of the trigger the panel aligns to. */
  align?: 'start' | 'end';
  /**
   * Panel minimum width in px (default 220, the guide's popover width). An
   * inline style, not a Tailwind class — a caller-supplied number can't be a
   * static `min-w-[Npx]` string for the JIT scanner to pick up.
   */
  minWidth?: number;
  className?: string;
  children: ReactNode;
}

interface MenuItemProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'> {
  /** Renders the item as a link: internal routes via the locale-aware Link, external URLs in a new tab. */
  href?: InternalHref | string;
  icon?: ReactNode;
  /** Red text for destructive actions. */
  danger?: boolean;
  children: ReactNode;
}
Modalsrc/components/ui/Modal.tsx
interface ModalProps {
  open: boolean;
  onClose: () => void;
  title?: string;
  /** Action row rendered bottom-right, e.g. a cancel Button + confirm Button. */
  footer?: ReactNode;
  className?: string;
  children: ReactNode;
}
pageWindow, Paginationsrc/components/ui/Pagination.tsx
// Numbered pager with prev/next chevrons. Current page = filled blue square;
// long ranges collapse to "1 … n-1 n n+1 … last".
interface PaginationProps {
  page: number;
  pageCount: number;
  onPageChange: (page: number) => void;
  className?: string;
}

type PageToken = number | typeof ELLIPSIS;
Picturesrc/components/ui/Picture.tsx
interface PictureBaseProps {
  /**
   * The image URL. Nullable on purpose: almost every image in this app comes
   * from an API field that may be absent, and the point of this component is
   * that a missing one renders `fallback` rather than a broken `<img>`.
   */
  src: string | null | undefined;
  /** Empty string for decorative art, as the HTML spec asks. */
  alt: string;
  className?: string;
  /**
   * Prefer `className`. This is for the rare box that has to beat Tailwind's
   * preflight (`max-width: 100%`, `height: auto`) with a specificity a utility
   * class cannot reach — the app-store badges, which a narrow menu panel
   * silently squeezed before they were pinned this way.
   */
  style?: CSSProperties;
  /**
   * Required whenever the image stretches — with `fill`, or with CSS that
   * makes it responsive. Without it the browser assumes the image is as wide
   * as the viewport and downloads the largest candidate in the srcset.
   */
  sizes?: string;
  /** Rendered in place of the image when `src` is empty. Nothing, by default. */
  fallback?: ReactNode;
  /**
   * `lazy` by default (that is `next/image`'s own default, and a change from
   * the raw `<img>` these replaced, which were all eager). Set `eager` for
   * anything above the fold — a page's hero, the header logo — where lazy
   * loading trades a little bandwidth for a visible pop-in and a worse LCP.
   */
  loading?: 'lazy' | 'eager';
  /**
   * Forces the image to be served as-is even when the host IS allowlisted.
   *
   * The one case that needs it: a layout whose box is the image's OWN
   * intrinsic size (no width and no height in CSS, only `max-*` caps).
   * Optimizing such an image attaches a density-descriptor srcset, and since
   * the optimizer never enlarges a source, a 2× candidate that comes back at
   * the source's own width renders at HALF the size on a retina screen. There
   * is no `sizes` value that fixes it — the fix is to not optimize.
   */
  unoptimized?: boolean;
  /**
   * How hard the optimizer should compress this image. Must be one of
   * `@/config/imageQuality`'s `IMAGE_QUALITIES`, which is what
   * `next.config.ts` allows the route to serve: Next 16 coerces an unlisted
   * value to the nearest listed one instead of reporting it, so an
   * out-of-list number is a silent difference rather than an error. Omit it
   * for `next/image`'s own default of 75.
   *
   * 60 for logos, avatars, icons and thumbnails; 75 for product and hero
   * photography; 85 for the carousel's full-bleed banner art.
   */
  quality?: ImageQuality;
  /**
   * Hint for the browser's request priority. `high` on the one image that is
   * the page's LCP candidate (a hero, the first carousel slide); leave it off
   * everywhere else, since marking everything high marks nothing high.
   */
  fetchPriority?: 'high' | 'low' | 'auto';
  /** For the error-fallback pattern (see Avatar), which needs the element itself. */
  ref?: Ref<HTMLImageElement>;
  /** Client components only — a server component cannot serialise a function. */
  onError?: ReactEventHandler<HTMLImageElement>;
}

type PictureProps = PictureBaseProps &
  (
    | {
        /**
         * Stretch to a positioned parent (`relative`/`absolute`/`fixed`) and
         * take no intrinsic size. Pair it with `object-cover`/`object-contain`
         * in `className` and always pass `sizes`.
         */
        fill: true;
        width?: never;
        height?: never;
      }
Progresssrc/components/ui/Progress.tsx
type ProgressSize = 'md' | 'lg';

interface ProgressProps {
  /** 0–100. */
  value: number;
  /** md = 12px tall (default), lg = 24px — the style guide's `.progress` / `.progress--lg`. */
  size?: ProgressSize;
  className?: string;
}
Radiosrc/components/ui/Radio.tsx
// Re-implements the style guide's custom `.radio` (16px circle, blue accent,
// 5px white center dot when checked, 30px total label offset) using the
// standard input+peer Tailwind pattern instead of the sibling-selector CSS
// it uses, since that's the idiomatic React/Tailwind equivalent.
type RadioProps = InputHTMLAttributes<HTMLInputElement> & { label: string };
Ratingsrc/components/ui/Rating.tsx
type RatingSize = 'sm' | 'md' | 'lg';

interface RatingProps {
  /** 0..max; fractions render as a partially filled star in read-only mode. */
  value: number;
  max?: number;
  size?: RatingSize;
  /** Providing onChange makes the stars clickable (whole stars only). */
  onChange?: (value: number) => void;
  'aria-label'?: string;
  /** Interactive mode only — announced on the radiogroup. */
  'aria-required'?: boolean;
  className?: string;
}
ReadMoreTextsrc/components/ui/ReadMoreText.tsx
interface ReadMoreTextProps {
  /**
   * Whether the content is actually long enough to be worth truncating —
   * gates whether the toggle renders at all. Computed by the caller from the
   * same character count used to build `truncated` (see
   * `truncateAtWordBoundary`), since this component never sees the raw text.
   */
  isLong: boolean;
  moreLabel: string;
  lessLabel: string;
  /**
   * Already-rendered short form. A `ReactNode`, not a string: the caller
   * typically builds this from an async server component (e.g. `RichText`),
   * which a client component can receive as children/props but can never
   * import and render itself.
   */
  truncated: React.ReactNode;
  /** Already-rendered full form. */
  full: React.ReactNode;
  className?: string;
}
RichTextsrc/components/ui/RichText.tsx
interface RichTextProps {
  markdown: string;
  className?: string;
}
Rowsrc/components/ui/Row.tsx
type RowAlign = 'start' | 'center' | 'end' | 'baseline';

type RowJustify = 'start' | 'center' | 'end' | 'between';

interface RowProps extends HTMLAttributes<HTMLElement> {
  as?: ElementType;
  /** Space between children. Defaults to the 20px gutter. */
  gap?: LayoutGap;
  align?: RowAlign;
  justify?: RowJustify;
  /** Rows wrap by default; disable for toolbars that must stay on one line. */
  wrap?: boolean;
  children: ReactNode;
}
Sectionsrc/components/ui/Section.tsx
type SectionSpacing = 'none' | 'md' | 'lg';

interface SectionProps extends HTMLAttributes<HTMLElement> {
  /** Inner content width; the section background always spans the full viewport. */
  size?: 'md' | 'lg';
  /** Vertical padding of the band: md 40px (default), lg 60px. */
  spacing?: SectionSpacing;
  /** Extra classes for the inner Container (e.g. text-center). */
  containerClassName?: string;
  children: ReactNode;
}
Selectsrc/components/ui/Select.tsx
type SelectSize = 'sm' | 'md';

// A plain native <select>, styled to the style guide's dropdown field metrics
// (60px tall, 3px radius, 16px text, 20px indent, navy chevron on the right).
// The guide's own dropdown is a custom searchable widget — that lives in
// Dropdown.tsx; this native select covers forms that don't need search with
// full keyboard/a11y behavior for free.
//
// The chevron is the icon font's dropdown glyph in an absolutely positioned
// span (a data-URI background class silently failed to compile — Tailwind
// drops arbitrary values containing raw spaces).
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
  /** md (default) is the guide's 60px field; sm the 40px compact variant, matching Input's. */
  size?: SelectSize;
}
Skeletonsrc/components/ui/Skeleton.tsx
type SkeletonVariant = 'text' | 'rect' | 'circle';

interface SkeletonProps {
  variant?: SkeletonVariant;
  /** Size via utilities, e.g. className="h-[160px]" or "h-[40px] w-[40px]" for a circle. */
  className?: string;
}
Spinnersrc/components/ui/Spinner.tsx
interface SpinnerProps {
  className?: string;
}
Stacksrc/components/ui/Stack.tsx
// The design system's spacing rhythm: 5px (metadata), 10px (tight),
// 20px (the guide's standard gutter), 40px (loose groups).
export type LayoutGap = 'xs' | 'sm' | 'md' | 'lg';

type StackAlign = 'start' | 'center' | 'end' | 'stretch';

interface StackProps extends HTMLAttributes<HTMLElement> {
  as?: ElementType;
  /** Vertical space between children. Defaults to the 20px gutter. */
  gap?: LayoutGap;
  align?: StackAlign;
  children: ReactNode;
}
Steppersrc/components/ui/Stepper.tsx
export type StepperOrientation = 'horizontal' | 'vertical';

interface StepperProps {
  /** Step labels, in order. */
  steps: string[];
  /** 0-indexed: the step the user is currently on. Earlier steps show done, later ones upcoming. */
  activeStep: number;
  orientation?: StepperOrientation;
  className?: string;
  /**
   * Vertical only: rendered indented under the active step's label, with the
   * connector stretching past it down to the next step — e.g. the form for
   * whichever leg of a multi-step flow the user is currently on. Ignored in
   * horizontal orientation.
   */
  activeContent?: ReactNode;
}

type StepState = 'done' | 'active' | 'upcoming';
StickyBannersrc/components/ui/StickyBanner.tsx
interface StickyBannerProps {
  title: string;
  children: ReactNode;
  ctaLabel: string;
  ctaHref: ComponentProps<typeof Button>['href'];
  dismissLabel: string;
  /**
   * A server action, like WelcomeCard's `dismissWelcomeCard` — the banner is
   * meant to persist a dismissal (a cookie, typically), and a form action is
   * the only way to do that write without an onClick handler that outlives
   * the element it's attached to.
   */
  dismissAction: () => Promise<void> | void;
  color?: ButtonColor;
  className?: string;
}
Table, TableRow, TableHeaderCell, TableCellsrc/components/ui/Table.tsx

No configurable props — see the source file.

Tabssrc/components/ui/Tabs.tsx
interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
}

interface TabsProps {
  tabs: TabItem[];
  defaultTab?: string;
  /** Label size in Text's tokens (sm 14 / md 16 / lg 20 px). Default: body size. */
  size?: TextSize;
}
TextLinksrc/components/ui/TextLink.tsx
type InternalHref = ComponentProps<typeof Link>['href'];

type Size = 'sm' | 'md' | 'lg';

type Underline = 'always' | 'hover' | 'none';

interface TextLinkProps
  extends Omit<ComponentProps<typeof Link>, 'href' | 'className' | 'onClick'> {
  /**
   * Internal route (locale-aware, typed) or an external URL/mailto/tel
   * string. Omit it (with an onClick) for a link-styled <button> — for
   * actions whose destination isn't known until they run.
   */
  href?: InternalHref | string;
  size?: Size;
  bold?: boolean;
  italic?: boolean;
  /** See UNDERLINE above. Defaults to `hover`. */
  underline?: Underline;
  className?: string;
  onClick?: React.MouseEventHandler<HTMLElement>;
  /** Button mode only — link renders ignore it. */
  disabled?: boolean;
}
Textareasrc/components/ui/Textarea.tsx
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
  /** Shown below the field in red and switches the border to red, mirroring Input's error state. */
  error?: string;
}
Toast, useToast, ToastProvidersrc/components/ui/Toast.tsx
type ToastVariant = 'success' | 'error' | 'warning' | 'info';

interface ToastProps {
  variant?: ToastVariant;
  onDismiss?: () => void;
  children: ReactNode;
}

interface ToastOptions {
  variant?: ToastVariant;
  /** Auto-dismiss delay in ms; 0 keeps the toast until dismissed. Default 4000. */
  duration?: number;
}

interface ToastItem {
  id: number;
  message: ReactNode;
  variant: ToastVariant;
}
Togglesrc/components/ui/Toggle.tsx
type ToggleColor = 'blue' | 'green' | 'red' | 'gold';

// The style guide's `.toggle`: 60×30px pill, grey when off, colored when on,
// with a round knob (40% wide, 80% tall, 5% margin) sliding to the middle.
type ToggleProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> & {
  color?: ToggleColor;
  'aria-label': string;
};
Tooltip, HoverTooltipsrc/components/ui/Tooltip.tsx
export type TooltipVariant = 'primary' | 'secondary';

export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';

interface TooltipProps {
  /** primary = navy, secondary = green — the style guide's two tooltip colors. */
  variant?: TooltipVariant;
  /** Which edge the 7px arrow sits on. */
  position?: TooltipPosition;
  shadow?: boolean;
  /**
   * The small, label-sized box — the one an icon's hover tooltip needs
   * (production's `.tooltip__content.center` beside the share icon, 14px type
   * in a 12/8 box). The default 20px padding and body type belong to the
   * style guide's LARGE tooltip, which holds a heading and a paragraph; at an
   * icon's scale that box is several times the trigger and reads as a popover.
   */
  compact?: boolean;
  className?: string;
  children: ReactNode;
}

interface HoverTooltipProps {
  /** The label itself — production's tooltips beside an icon hold one word. */
  content: ReactNode;
  position?: TooltipPosition;
  variant?: TooltipVariant;
  /** Extra classes for the WRAPPER, not the box (e.g. an absolute pin). */
  className?: string;
  /** The trigger. It keeps its own `aria-label`; see below. */
  children: ReactNode;
}
Header, SubHeader, Text, Caption, MetaText, Quotesrc/components/ui/Typography.tsx
type Weight = 'regular' | 'bold' | 'black';

interface TypeProps extends HTMLAttributes<HTMLElement> {
  as?: ElementType;
  italic?: boolean;
  className?: string;
  children: ReactNode;
}

// Headers scale down on smaller screens the same way the guide does via its
// responsive root font-size: full size >1200px, ×0.9 on tablets, ×0.8 on
// phones (guide breakpoints 1200/600px ≈ Tailwind's xl/sm).
type HeaderSize = 'sm' | 'md' | 'lg';

type SubHeaderSize = 'sm' | 'md' | 'lg';

// Deliberate deviation from the legacy guide (decided 2026-08-20): sm is
// 14px, not the guide's 12px — 12px reads too small as running text, and
// production's cards use 14px. For 12px small print use <Caption> instead.
// Exported because the tab rows (ui/Tabs and its routed mirror lists/LinkTabs)
// take their label size in these same tokens, and there must be exactly one
// place the token -> px mapping lives.
export type TextSize = 'sm' | 'md' | 'lg';

type MetaTextSize = 'sm' | 'md' | 'lg';

Denna webbplats använder cookies.

Vi använder cookies för att förbättra din upplevelse på vår webbplats och anpassa innehållet vi tillhandahåller dig. Viktiga cookies är nödvändiga för att vår tjänst ska fungera korrekt. Om du går med på att vi samlar in information om dig genom användningen av nödvändiga och marknadsförande cookies, välj "Acceptera alla". Du kan alltid ändra dina preferenser genom att besöka Cookie -inställningar.

Cookie -inställningar