TRTomas RainboltFrontend + Product

Design system / v0.1

Product Room System

Small system. Big impact.

This portfolio is the working example. The foundations below feed its styles; the specimens render the same React components used on the homepage. A focused library for a specific product, with decisions you can inspect.

Try it in another light

Change modes, not moods.

Compare the palette, typography, and components in light or dark. System follows your device; your choice applies across the site.

System preview appearance

01 / Foundations

Each token has a job to do.

The colors change. Their purpose stays the same.

Navy and ivory trade places between appearances, while each color keeps its role. Violet identifies the primary action; its darker fill keeps small white labels readable. Orange marks keyboard focus, with a deeper shade on light surfaces. Borders divide content without introducing another card around everything.

Destructive action--studio-destructive#B52E38#B52E38
Destructive hover--studio-destructive-hover#96222C#96222C
Page canvas--studio-canvas#06101D#F7F4ED
Headings + emphasis--studio-ink#F2ECDF#142133
Body + metadata--studio-muted#A6B3C4#526174
Brand accent--studio-accent#8A61FF#7040D7
Text on actions--studio-accent-ink#FFFFFF#FFFFFF
Primary action--studio-action-fill#7C52ED#7040D7
Keyboard focus--studio-focus#FF7A1A#B74600
Section boundaries--studio-line#2B3B4B#D1D4D8
Code surface--studio-surface#0D1A2B#EEEBE4
Section labels--studio-label-ink#B397FF#6531BA
Control boundary--studio-border-control#637084#7A8491
Primary hover--studio-action-hover#6940D4#5C2BBD
Navigation surface--studio-rail#050F1A#EEEBE4
Supporting lines--studio-subtitle#B9C3D1#475669
Secondary hover--studio-control-hover#142233#E6E1D8
Inspect tokens.css — the actual stylesheet
/* Shared foundations. Read directly by /system at build time. */
:root {
  color-scheme: dark;
  --studio-destructive: #b52e38;
  --studio-destructive-hover: #96222c;
  --studio-rail: #050f1a;
  --studio-subtitle: #b9c3d1;
  --studio-control-hover: #142233;
  --studio-canvas: #06101d;
  --studio-ink: #f2ecdf;
  --studio-muted: #a6b3c4;
  --studio-accent: #8a61ff;
  --studio-accent-ink: #ffffff;
  --studio-action-fill: #7c52ed;
  --studio-focus: #ff7a1a;
  --studio-line: #2b3b4b;
  --studio-disclosure-fill: color-mix(in srgb, var(--studio-line) 18%, transparent);
  --studio-space-1: 0.5rem;
  --studio-space-2: 1rem;
  --studio-space-3: 1.5rem;
  --studio-space-4: 2rem;
  --studio-space-6: 3rem;
  --studio-space-8: 4rem;
  --studio-control-height: 2.75rem;
  --studio-duration: 160ms;
  --studio-surface: #0d1a2b;
  --studio-label-ink: #b397ff;
  --studio-border-control: #637084;
  --studio-action-hover: #6940d4;
  --studio-text-heading: var(--studio-ink);
  --studio-text-subtitle: var(--studio-subtitle);
  --studio-text-body: var(--studio-muted);
  --studio-type-body: 1rem;
  --studio-leading-body: 1.75;
  --studio-type-caption: 0.875rem;
  --studio-type-subtitle: clamp(1rem, 1.65vw, 1.375rem);
  --studio-type-label: 0.75rem;
  --studio-type-title: clamp(2rem, 3.4vw, 3rem);
  --rail-width: clamp(10rem, 14.2vw, 12.75rem);
}

:root[data-theme="light"] {
  color-scheme: light;
  --studio-destructive: #b52e38;
  --studio-destructive-hover: #96222c;
  --studio-canvas: #f7f4ed;
  --studio-ink: #142133;
  --studio-muted: #526174;
  --studio-accent: #7040d7;
  --studio-accent-ink: #ffffff;
  --studio-action-fill: #7040d7;
  --studio-focus: #b74600;
  --studio-line: #d1d4d8;
  --studio-surface: #eeebe4;
  --studio-label-ink: #6531ba;
  --studio-border-control: #7a8491;
  --studio-action-hover: #5c2bbd;
  --studio-rail: #eeebe4;
  --studio-subtitle: #475669;
  --studio-control-hover: #e6e1d8;
}

02 / Typography + rhythm

Typography

Geist and IBM Plex Mono

Headings use the strongest ink, subtitles step down, and body copy is slightly muted. Reading copy uses a steady 16px size and generous line height across every page. Titles scale with the viewport. Labels, navigation, links, and buttons share mono; emphasis comes from placement and weight, rather than another font size.

Section title / fluid 32–48 · heading ink

Clarity through structure.

Supporting line / 16–22 · subtitle ink

Related thoughts stay together.

Body / 16 · 1.75 · muted

Enough room to read, enough structure to scan. Each paragraph develops one idea, with a measure that stays comfortable as the screen gets wider.

UI + labels / 12 · monoView selected work
Explore the full typography reference

The role determines color, size, and rhythm. Heading size responds to its context; body, caption, subtitle, and label roles are shared across every page.

Geist / reading + display
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789 & @ ! ? ( )
IBM Plex Mono / interface
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789 / : ; →
Weights / Geist
400 — Clear interfaces500 — Clear interfaces600 — Clear interfaces700 — Clear interfaces
Subtitle / 16–22 · 1.5

A supporting line stays close to the heading it explains.

Body / 16 · 1.75

Body copy carries the story in a slightly muted tone. A comfortable line length gives each idea enough room to develop without stretching across the whole screen.

A new paragraph starts a new thought. Emphasis can be strong or italic without introducing another text size.

Caption / 14 · 1.75

Supporting context, figure descriptions, and specimen notes use the caption role.

Label / 12 · 1.6 · mono

Featured project / Product systems

Role mapping
Heading
--studio-text-heading
Subtitle
--studio-text-subtitle
Body + caption
--studio-text-body

Use semantic heading levels for document structure. Use body for narrative, caption for supporting context, and mono labels for interface metadata. Text wraps naturally; avoid fixed-height containers that clip enlarged text.

Inspect typography.css — text roles used across the site
/* These roles style the live site and its system specimens. Layout stays with the component. */
:root [data-text="body"] {
  color: var(--studio-text-body);
  font-family: var(--font-display), sans-serif;
  font-size: var(--studio-type-body);
  font-weight: 400;
  line-height: var(--studio-leading-body);
  letter-spacing: normal;
}
:root [data-text="subtitle"] {
  color: var(--studio-text-subtitle);
  font-family: var(--font-display), sans-serif;
  font-size: var(--studio-type-subtitle);
  font-weight: 400;
  line-height: 1.5;
  letter-spacing: -.015em;
}
:root [data-text="caption"] {
  color: var(--studio-text-body);
  font-family: var(--font-display), sans-serif;
  font-size: var(--studio-type-caption);
  font-weight: 400;
  line-height: 1.75;
  letter-spacing: normal;
}
:root [data-text="label"], .studio-label, .technical-label, .eyebrow {
  color: var(--studio-muted);
  font-family: var(--font-mono), monospace;
  font-size: var(--studio-type-label);
  font-weight: 500;
  line-height: 1.6;
  letter-spacing: .08em;
  text-transform: uppercase;
}
.studio-label-accent, .technical-label-violet,
:root [data-text="label"].studio-label-accent,
:root [data-text="label"].technical-label-violet { color: var(--studio-label-ink); }
.technical-label-orange, :root [data-text="label"].technical-label-orange { color: var(--studio-focus); }
:root :is(h1, h2, h3, h4, h5, h6) { color: var(--studio-text-heading); }
/* Shared separation between ordinary narrative paragraphs and section titles. */
:where(.page-wrap) :is(h1, h2, h3) + [data-text="body"] { margin-top: var(--studio-space-3); }
:where(.page-wrap) [data-text="body"] + [data-text="body"] { margin-top: var(--studio-space-3); }
0.5remspace-1
1remspace-2
1.5remspace-3
2remspace-4
3remspace-6
4remspace-8

8px connects a title and its supporting line. 24px separates the label and title group, then the supporting content. 32px introduces an action. 64px gives sections room to change subject.

03 / Actions

It should be clear what happens next.

Different actions, a familiar set of controls.

Links navigate. Buttons perform an action. Both share the same variants, a 44px minimum height, and mono labels. Visual emphasis is separate from behavior: StudioLink navigates, while StudioButton performs an action. Destructive identifies a consequence, rather than a level of emphasis.

Each variant appears enabled first, then disabled.

primary

The main next step. Give it priority within its group.

secondary

A quieter alternative with a filled surface.

outline

A bounded action without a filled surface.

ghost

A borderless control with a hover surface. Its transparent border preserves spacing.

text

A quiet text action without a container or underline.

link

An underlined treatment, often used for navigation.

destructive

An action that removes data. Its label states the consequence.

These are interactive specimens. They do not save or delete data.

Hover changes the surface; Tab reveals the orange focus outline. Disabled buttons retain their dimensions and cannot activate. Reduced-motion preferences remove transitions. Open the source below to try a real secondary button: Copy source.

Every element should feel at home.

Appearance uses the shared StudioSelect component. The menu follows the active palette, with a check for the selected value and a distinct keyboard highlight. Arrow keys move through choices; Enter selects and Escape closes.

Inspect select.tsx — the shared Radix select
'use client';

import * as Select from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';

export type StudioSelectOption = { value: string; label: string; disabled?: boolean };

export function StudioSelect({ value, onValueChange, options, label, id, disabled = false }: {
  value: string;
  onValueChange: (value: string) => void;
  options: readonly StudioSelectOption[];
  label: string;
  id?: string;
  disabled?: boolean;
}) {
  return <Select.Root value={value} onValueChange={onValueChange} disabled={disabled}>
    <Select.Trigger id={id} className="studio-select-trigger" aria-label={label}>
      <Select.Value />
      <Select.Icon asChild><ChevronDown size={14} aria-hidden="true" /></Select.Icon>
    </Select.Trigger>
    <Select.Portal>
      <Select.Content className="studio-select-content" position="popper" sideOffset={6} collisionPadding={12}>
        <Select.ScrollUpButton className="studio-select-scroll"><ChevronUp size={14} aria-hidden="true" /></Select.ScrollUpButton>
        <Select.Viewport className="studio-select-viewport">
          {options.map(option => <Select.Item key={option.value} value={option.value} disabled={option.disabled} className="studio-select-option">
            <Select.ItemText>{option.label}</Select.ItemText>
            <Select.ItemIndicator className="studio-select-check"><Check size={14} aria-hidden="true" /></Select.ItemIndicator>
          </Select.Item>)}
        </Select.Viewport>
        <Select.ScrollDownButton className="studio-select-scroll"><ChevronDown size={14} aria-hidden="true" /></Select.ScrollDownButton>
      </Select.Content>
    </Select.Portal>
  </Select.Root>;
}
Inspect action.tsx — links and buttons
import Link from 'next/link';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';

export type ActionVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'text' | 'link' | 'destructive';

export function StudioLink({ variant = 'primary', className, ...props }: ComponentProps<typeof Link> & { variant?: ActionVariant }) {
  return <Link className={cn('studio-action', `studio-action-${variant}`, className)} {...props} />;
}

export function StudioButton({ variant = 'primary', className, type = 'button', ...props }: ComponentProps<'button'> & { variant?: ActionVariant }) {
  return <button type={type} className={cn('studio-action', `studio-action-${variant}`, className)} {...props} />;
}

04 / Composition

Spacing helps tell the story.

Keep related thoughts together. Give new ones some room.

The homepage and these section openings use SectionIntro. The label introduces a subject, the subtitle stays close to its heading, and supporting copy sits in its own group. The example below is the component itself.

Featured project

PopHops

From campaign setup to mobile experience.

Product direction · Frontend architecture · UI design

Explore the case study
Label → title group
24px
Title → subtitle
8px
Group → supporting copy
24px
Copy → action
32px
Inspect section-intro.tsx — shared composition
import type { ReactNode } from 'react';

export function SectionIntro({ label, title, subtitle, id, children }: {
  label: string; title: string; subtitle: string; id?: string; children?: ReactNode;
}) {
  return <div className="studio-section-intro">
    <p data-text="label" className="studio-label studio-label-accent">{label}</p>
    <div className="studio-title-group">
      <h2 id={id}>{title}</h2>
      <p data-text="subtitle" className="studio-subtitle">{subtitle}</p>
    </div>
    {children ? <div className="studio-section-support">{children}</div> : null}
  </div>;
}

Show the idea. Open the detail.

Work introduces each chapter with a short headline, a vector illustration, and one path into the full story. The same alternating composition can introduce other subjects. Screens keep their original proportions, while gentle motion pauses offscreen and respects reduced-motion preferences.

See visual stories in use
Inspect visual-story.tsx — reusable illustrated chapters
import type { ReactNode } from 'react';
import { ArrowRight } from 'lucide-react';
import { StudioLink } from '@/components/studio/action';
import { MotionScene } from './motion';

export function VisualStory({ id, number, lines, summary, href, link, visual, reverse = false }: {
  id: string; number: string; lines: readonly string[]; summary: string; href: string; link: string; visual: ReactNode; reverse?: boolean;
}) {
  return <section id={id} className={`visual-story ${reverse ? 'visual-story-reverse' : ''}`} aria-labelledby={`${id}-title`}>
    <MotionScene className="visual-story-art">{visual}</MotionScene>
    <div className="visual-story-copy">
      <div className="story-heading-group"><p data-text="label" className="technical-label technical-label-orange">Chapter {number}</p><h2 id={`${id}-title`}>{lines.map(line => <span key={line}>{line}</span>)}</h2></div>
      <span className="story-rule" aria-hidden="true" />
      <p data-text="body">{summary}</p>
      <StudioLink href={href} variant="link">{link}<ArrowRight size={16} aria-hidden="true" /></StudioLink>
    </div>
  </section>;
}
Inspect screen-frame.tsx — replaceable product imagery
import Image from 'next/image';
import type { CSSProperties } from 'react';
import { workScreens } from '@/content/work-stories';

export function ScreenFrame({ screen, phone = false, priority = false, className = '' }: {
  screen: keyof typeof workScreens; phone?: boolean; priority?: boolean; className?: string;
}) {
  const asset = workScreens[screen];
  return <div className={`story-screen ${phone ? 'story-screen-phone' : 'story-screen-desktop'} ${className}`} style={{ '--screen-ratio': phone ? '1206 / 2622' : `${asset.width} / ${asset.height}` } as CSSProperties}>
    <Image src={asset.src} alt={asset.alt} fill priority={priority} quality={95} sizes={phone ? '(max-width: 600px) 32vw, 18vw' : '(max-width: 600px) 75vw, 48vw'} />
  </div>;
}

05 / Build + scope

This is the system the site actually uses.

Next.js App Router · React · CSS custom properties

The library lives beside the interface it serves. This page reads the token and component files on the server at build time, so the source shown here comes from the same revision as the site.

  1. src/app/tokens.css

    Foundations

    Named color, spacing, type, and motion values. The root layout imports these before the component styles. Geist and IBM Plex Mono are loaded through next/font.

  2. src/components/studio/

    Shared interface

    StudioLink and StudioButton preserve native element behavior. SectionIntro owns the heading relationships. These components render directly in the homepage and this reference.

  3. src/app/studio.css + system.css

    Responsive composition

    The rail becomes mobile navigation at 760px. The homepage hero stacks at 1000px. Content groups reflow while keeping their internal spacing and reading order.

  4. src/app/system/page.tsx

    Server by default

    The reference is a server-rendered page. Appearance controls and source copying use client state. Dark is the default; a saved preference is applied before the first paint, and System responds to your device setting. Native disclosure elements handle code examples.

Where this stands

Every page uses the same heading, subtitle, body, caption, and label roles in both appearances. The homepage and this reference also share SectionIntro. Work, About, and Contact keep layouts suited to their content; forms and galleries are specialized patterns rather than a separate typography system.

Back to the portfolio