Harmony

Design System

The visual foundation for Harmony — tokens, type, color, spacing, and motion. Everything on this page is driven by the CSS variables in app/globals.css, the single source of truth for the design system.

Principles

Three ideas that every design decision should reinforce.

Friendly

Music is social. Rounded shapes, warm purples, and approachable type over sharp corners and stark contrast.

In sync

One experience across every streaming service. Platform branding stays subordinate to Harmony's own language.

Effortless

Sharing a playlist should feel like one gesture. Interfaces stay light — generous whitespace, few borders, clear hierarchy.

Branding & Marketing Assets

Reserved slots for the assets a real launch needs beyond the app UI — logo variants, platform icons, and social previews. Filed here now so there's one checklist to work through instead of scrambling for sizes later.

Logo lockups

The official Harmony logo assets stored in /public/branding/harmony/. Use these pre-built SVGs to ensure geometric consistency across all surfaces rather than recreating the brand elements in code.

Harmony Mark

Mark

/branding/harmony/logo.svg

App icons, favicons, avatars, loading states

Harmony Combined Logo & Wordmark

Full lockup

/branding/harmony/combined.svg

Wordmark + mark, horizontal — nav bars, marketing headers

Harmony Wordmark

Wordmark

/branding/harmony/wordmark.svg

Text only, no mark — tight spaces, footer credits

Harmony Mono Logo

Reversed / mono

/branding/harmony/mono.svg

Single flat color — dark surfaces, watermarks, stamps

App icons & favicons

Next.js wires these up automatically by file name — drop an image at the given path and it's added to the page's <head> with no code changes. Android icons are referenced from an app/manifest.ts file, which doesn't exist yet either — add both together.

Favicon

Favicon

app/favicon.ico

48×48, multi-res .ico — browser tabs, bookmarks

Icon (SVG)

Icon (SVG)

app/icon.svg

Scalable — modern browsers, crisp at every size

Apple touch icon

Apple touch icon

app/apple-icon.png

180×180 — iOS/iPadOS home screen, Safari

Android icon

Android icon

public/icons/icon-192.png

192×192 — Android home screen

Android icon (large)

Android icon (large)

public/icons/icon-512.png

512×512 — splash screens, high-DPI displays

Maskable icon

Maskable icon

public/icons/maskable-icon.png

512×512, ~40% safe zone — adaptive Android icon shapes

Social & store previews

Open Graph and Twitter images are also file-name conventions in app/. The store icon has no code hook — it's uploaded directly to App Store Connect / Google Play Console.

Open Graph image

Open Graph image

app/opengraph-image.png

1200×630 — link previews (iMessage, Slack, Facebook)

Twitter / X card

Twitter / X card

app/twitter-image.png

1200×675 — link previews on X

App store icon

App store icon

public/icons/app-store-icon.png

1024×1024, no transparency or rounding — App Store / Play listing

Color

White-first with purple as the brand color. Tokens are semantic — components reference roles like primary or muted, never raw hex values, so the palette can evolve in one place.

Surfaces & neutrals

--background

#FAFAFA / #09090B

Page background (tinted, sits below cards)

--foreground

#18181B / #FAFAFA

Primary text

--card

#FFFFFF / #18181B

Card surfaces — the elevated, brightest layer

--muted

#F4F4F5 / #27272A

Subtle fills, wells

--muted-foreground

#71717A / #A1A1AA

Secondary text

--border

#E4E4E7 / #27272A

Dividers, outlines

Brand

--primary

#9333EA / #A855F7

Buttons, links, key actions

--primary-foreground

#FFFFFF / #3B0764

Text on primary

--secondary

#F3E8FF / #3B0764

Tinted fills, selected states

--secondary-foreground

#6B21A8 / #E9D5FF

Text on secondary

--accent

#A855F7 / #C084FC

Highlights, hover accents

--ring

#9333EA / #A855F7

Focus rings

--heading

#9333EA / #FAFAFA

Display + page titles — purple in light, foreground in dark

Feedback

--success

#16A34A / #22C55E

Confirmations, synced states

--warning

#D97706 / #F59E0B

Caution, pending states

--destructive

#DC2626 / #EF4444

Errors, deletion

--info

#2563EB / #3B82F6

Neutral notices

Purple ramp

The brand tokens draw from Tailwind's purple family. Use the ramp for tints, gradients, and states rather than inventing new purples.

50

100

200

300

400

500

600

700

800

900

950

Typography

Righteous is the display face for the wordmark and marketing moments. Outfit carries in-app headings with a clean, modern voice. DM Sans keeps body copy and UI text highly legible, and Geist Mono handles code and tabular data.

Aa

Righteous

Display & wordmark

font-display

Aa

Outfit

Headings (h1–h4)

font-heading

Aa

DM Sans

Body & UI

font-sans

Aa

Geist Mono

Code & data

font-mono

Type scale

Display

Righteous · font-display text-6xl tracking-tight

HARMONY

Heading 1

Outfit · font-heading text-4xl font-semibold tracking-tight

Find your people through music

Heading 2

Outfit · font-heading text-3xl font-semibold tracking-tight

Playlists worth sharing

Heading 3

Outfit · font-heading text-2xl font-semibold

Your friends are listening

Heading 4

Outfit · font-heading text-xl font-medium

Recently added tracks

Body Large

DM Sans · text-lg

Connect your streaming service and share playlists with friends on any platform.

Body

DM Sans · text-base

Harmony translates playlists between Spotify, Apple Music, and more, so a shared link just works no matter where your friends listen.

Body Small

DM Sans · text-sm

Added 3 tracks to Road Trip Mix · 2 hours ago

Label

DM Sans · text-sm font-medium

Playlist name

Caption

DM Sans · text-xs text-muted-foreground

Synced from Spotify · 42 songs · 2 hr 38 min

Overline

DM Sans · text-xs font-medium uppercase tracking-widest

Now playing

Code

Geist Mono · font-mono text-sm

GET /api/playlists/road-trip-mix

Capitalization

Two simple rules keep the hierarchy legible without extra visual weight.

Page H1

Title Case

Friends · Playlists · Account

These are destination names — navigation labels rendered on-page. Title Case signals "you've arrived somewhere" and mirrors the tab-bar labels.

Card & dialog headings

Sentence case

Shared with you · Edit profile · Add a friend

Content nested inside a page feels conversational and inline. Sentence case reads faster and avoids competing with the page title for visual weight.

Section subheadings (h2–h4)

Sentence case

Connected platforms · Recent activity

Sub-hierarchy within a page. Sentence case reinforces that these are content, not navigation landmarks.

Button labels

Sentence case

Add friend · Save changes · Sign out

Actions read naturally as short imperative phrases. Title-casing action labels makes them feel stiff.

Navigation labels (tab bar)

Title Case

Friends · Playlists · Account

Landmark labels, not prose. Title Case matches OS conventions (iOS tab bars, Android bottom nav) and the page H1 they link to.

Iconography

Lucide (lucide-react) is the icon library — installed and wired into components.json so shadcn components share the same set.

  • Sizes: 16px inline with text, 20px in buttons and inputs, 24px standalone.
  • Stroke width 2 (Lucide default) everywhere — never mix weights.
  • Icons inherit text color (currentColor); use text-muted-foreground for decorative icons.

App Layout

The signed-in app is three top-level destinations — /friends, /playlists, /account — with a bottom tab bar at every breakpoint. The routes stay identical on mobile and desktop (each view expands rather than merging into a dashboard), so shared links and the back button never depend on screen size.

Bottom tab bar

components/app/app-nav.tsx, mounted once in the (app) route-group layout. Fixed to the bottom edge, 64px tall, lucide icons at 20px with a text-xs label. The active tab uses text-primary; the rest are muted. Static preview:

FriendsPlaylistsAccount

Page conventions

  • Container: mx-auto max-w-2xl px-6 py-16 with a font-heading text-3xl page title. The (app) layout reserves the nav's height, so pages never need their own bottom clearance.
  • Every app page guards itself with requireProfile() (lib/supabase/require-user.ts) — signed-out visitors land on the landing page, username-less ones on /create-account.
  • Streaming-service content gets one card per platform, never blended lists, with the official logo and a link back to the service in the card header — see docs/branding/ for the rules this satisfies.

Spacing

Everything sits on a 4px grid — Tailwind's default scale, where each step is a multiple of 4 (p-4 = 16px). Stick to the steps below; if a design seems to need an in-between value, adjust the layout instead.

1

4px

2

8px

3

12px

4

16px

6

24px

8

32px

12

48px

16

64px

24

96px

Shape & Elevation

Corners are generously rounded to match the brand's friendly voice. The whole radius scale derives from one token (--radius: 12px), so the entire app can get sharper or softer by changing a single line.

Radius

rounded-sm

4px

Checkboxes, tiny chips

rounded-md

8px

Inputs, small buttons

rounded-lg

12px

Buttons, cards (default)

rounded-xl

16px

Large cards, dialogs

rounded-2xl

24px

Hero panels, album art frames

rounded-3xl

26.4px

Sheets, large surfaces

rounded-4xl

31.2px

Oversized feature panels

rounded-full

9999px

Avatars, pills, play buttons

Elevation

Shadows are subtle — hierarchy comes primarily from spacing and color. Reserve stronger shadows for surfaces that float above the page.

shadow-xs

Cards at rest

shadow-sm

Raised cards, inputs

shadow-md

Dropdowns, popovers

shadow-lg

Dialogs, sheets

shadow-xl

Toasts, command palette

Motion

Fast and unobtrusive: quiet ease-outs, no bounce. Motion explains what just changed — where something went, what took its place — and then gets out of the way. A few earned moments get a little more.

Duration

Hover the cards to feel each one. Leaving is faster than entering, so dismissing something never feels like waiting.

100ms

Hover and press feedback

150ms

Anything leaving; colour changes

200ms

Anything entering; a gap closing; a page arriving after its wait

250ms

A surface resizing, a tile moving

300ms

A card arriving on a page

500ms

Celebrations, and nothing else

Easing

ease-out

Entering — arrives quickly, settles gently

ease-in

Leaving — gets out of the way

ease-in-out

Moving or resizing in place

Rules

Artwork and logos are never transformed.

Containers may slide and fade; the art inside is never scaled, cropped, blurred or morphed. A row fades out before it collapses, so art is never seen being cut.

Typing never animates.

Rows come and go instantly as a search narrows, and a dialog keeps its height while a search is active — it's centred, so resizing would move the field being typed in.

Tab switches are instant.

Like a native tab bar: a tab you've already visited is simply there, and no route change slides or crossfades. The one thing a page body does is arrive — when a loading state was on screen, what replaces it fades in.

Reduce Motion means fades only.

Movement and resizing snap; opacity and colour still ease. The spinner keeps spinning — a static one reads as broken.

Celebrate three things.

A transfer finishing, a playlist sent, a new friend — when they happen, never for something already true on load, and never for anything destructive.

The right tool

Motion (the library) isn't used for everything. Where Base UI or CSS already does the job, that's simpler and more robust.

A dialog or menu opening and closing

CSS, on Base UI's own hooks

Base UI owns mounting and already waits for CSS; a transition reverses cleanly mid-flight.

A shelf expanding

CSS, on the height Base UI measures

Built into the primitive.

A card arriving on a server-rendered page; a page taking over from its loading state

A CSS keyframe

Plays from the first paint. A JavaScript entrance would hide server-rendered content until hydration.

Height, presence, reordering, the celebration

Motion

What CSS can't do: animate to content height, or keep something mounted while it leaves.

Primitives

Live, and built from the same components the app uses. Turn on Reduce Motion in the debug menu to see each one's reduced form.

Dialog

components/ui/dialog.tsx

Opens with a fade and an 8px rise (200ms in, 150ms out) — never a zoom, which would scale cover art. When its content changes size, the height eases to it.

Swap

components/motion/swap.tsx

Share

Views of one region trade places: a crossfade, with a small slide along the direction of travel. The leaving view is lifted out at once, so there is no blank frame between them.

Reveal

components/motion/reveal.tsx

Pick at least one friend.

Something appearing mid-layout grows from nothing, so what's below is pushed rather than jumped. Error lines, a button that only exists once there's something to send. axis="x" grows it sideways, for something joining a row: a service's menu once a second service is linked.

Resize

components/motion/resize.tsx

Linked July 4, 2026

A surface whose content changes height in place eases to it — the dialog's resizer, for any box. A service's row on a phone, when its date wraps. Height only, so nothing inside is scaled; it clips while it eases, so top-align what sits beside the growing part.

AnimatedListItem

components/motion/animated-list-item.tsx

  • Logan
  • Andrew
  • Abbas
  • Matea

A row fades out, THEN the list closes the gap — so cover art is already invisible by the time its row is clipped. Search never uses this: rows that don't match are hidden, not removed.

Collapsible

components/ui/collapsible.tsx

Eases on a tap, in CSS on the height Base UI measures. Pass `instant` when something other than a tap moved it — a search forcing a shelf open.

Pop · NotificationBadge · SwapLabel

components/motion/pop.tsx

Small marks pop into place; a badge bumps when its count changes; a button's label crossfades. Pop scales what's inside it, so it's for glyphs and counts only — never art or a logo.

SuccessCheck

components/motion/success-check.tsx

Nothing sent yet

The celebration, for three moments only: a transfer finishing, a playlist sent, a new friend. The check draws itself and one ring opens behind it — the landing page's rings, once. The panel clips the ring.

Loading

A placeholder only claims what it knows. A list of things waits as one spinner in the space the list will take, never as rows, because a row is a claim about how many there will be. What makes a wait feel broken isn't the spinner: it's the page moving when the wait ends.

Pick by what is known

Something is already on screen

The old content, dimmed

A reload, a refresh, a second search. It stays where it is until the new content replaces it; the control that started the wait shows it.

The size of what's coming

A box of exactly that size

A track list knows its track count before its tracks load. The spinner sits in a box the height the list will have, and nothing moves when the tracks arrive.

Nothing about its size

A region that moves at most once

A page's list takes the rest of the page. A dialog's opens at its full height and settles when everything inside has answered. A section with others below it is one row tall, and grows.

It's furniture

A skeleton, at its real size

Header buttons, the search field, the profile card, the four favorite tiles, the three service cards: the same shape whoever is signed in.

Rules

Never draw rows you can't count.

A skeleton row is a claim about how many there will be. A list of N things waits as one spinner, in the space the list will take.

The box stops the resize, not the spinner.

A wait that's a different height from what replaces it makes a dialog grow or shrink — and a dialog is centred, so its title moves too. Size the region first.

Nothing is drawn over rows.

They carry cover art. A dimmed list's wait shows in the search field or the button that started it, which stays busy until the new content is on screen.

A beat in a dialog, none on a page.

In a dialog or a card the spinner waits 150ms, then fades in: a quick load never shows it. A page's loading state is up for about 300ms regardless, so its spinner is there from the first frame.

A wait ends with a fade.

A page taking over from its loading state fades in where the skeletons and the spinner stood: 200ms, nothing travels, and the title holds still. Only when there was a wait — a tab back from the cache is simply there.

What holds a height lets go once.

The favorites picker opens at full height and is released when every service has answered — for good, so a later wait inside it can't stretch it again.

In use

Live, and built from the same components the app uses. Each one says what should hold still.

ListLoading

components/ui/list-loading.tsx

size="page"

size="region"

One spinner in the space the list will take. On a page: 24px, there from the first frame. In a dialog or a card: 16px, and it waits a beat (150ms) before fading in, so a quick load never shows it.

A known size, reserved

components/playlists/track-list.tsx

A track list knows how many tracks are coming before they load, so the wait is a box of exactly the list's height. Load it again: the button below it doesn't move.

What's there stays

components/playlists/playlists-view.tsx

  • HYPE UP
  • gym (new)
  • Focus

A reload keeps the old list on screen, dimmed, until the new one replaces it. The wait shows in the control that started it, and lasts until the new list is in. Nothing is drawn over the rows.

One spinner taking over from another

components/ui/list-loading.tsx

A route's loading state hands over to the friends list while it waits for avatars: a different element, in the same place. Every spinner turns in step with the clock, so the new one carries on from the angle the old one had reached.

A page arriving

components/ui/arrive.tsx

Playlists

When a route's loading state ends, what replaced the skeletons and the spinner fades in where they stood (200ms). The title is the same in both states, so it holds still and never blinks. Only after a wait: a tab that comes back from the cache is simply there.

Components

Live examples of the shadcn/ui components adopted so far (Base UI primitives — see components.json). Source lives in components/ui/ and is themed entirely by the tokens above, so these examples double as a visual check that the system holds together.

Button

components/ui/button.tsx

default for the primary action on a surface; outline/ghost for everything else. destructive is reserved for irreversible actions.

Alert

components/ui/alert.tsx

Three established patterns: default for neutral notices, the warning-token treatment for setup/caution states, destructive for failures.

Badge

components/ui/badge.tsx

Default42 tracksOutlineErrorConnected

The success-token badge is the established “connected” indicator for a linked streaming service.

Avatar

components/ui/avatar.tsx

NHA

Fallback initials sit on the secondary tint in the display font.

Card

components/ui/card.tsx

Road Trip Mix
42 songs · 2 hr 38 min

Shared by Nick · synced from Spotify

Tabs

components/ui/tabs.tsx

Everything about this playlist at a glance.

Underline-style tabs for switching between views within one surface.

Skeleton

components/ui/skeleton.tsx

Friends

For furniture only: what is the same shape whoever is signed in (here, a page's header buttons and its search field), at the size of what it stands for. A list of things waits as a spinner instead — see Loading.

Separator & Spinner

components/ui/separator.tsx · spinner.tsx

Now playing

Up next

size-4 · size-6

The spinner on its own is for a control that's busy: a button, a search field. Where a list will be, it goes through ListLoading — see Loading.

Input & Label

components/ui/input.tsx · label.tsx

Label pairs with an input via htmlFor/id; focus uses the ring token.

Dialog

components/ui/dialog.tsx

Modal on Base UI — the shares inbox and share-playlist flows use it.

Dropdown Menu

components/ui/dropdown-menu.tsx

The “Add to library” destination picker in the shares inbox is this menu.

Notification Badge

components/ui/notification-badge.tsx

Count bubble pinned to an icon (inbox, friend requests); nothing at zero, “9+” past nine.

Using the Tokens

How the system stays enforceable in code.

Every token is a Tailwind utility

Tokens are defined once in app/globals.css and mapped through @theme, so they are available as normal utility classes:

<button className="bg-primary text-primary-foreground rounded-lg px-4 py-2">
  Share playlist
</button>

<p className="text-muted-foreground">42 songs · 2 hr 38 min</p>

<div className="border border-border bg-card rounded-xl p-4">…</div>

Ground rules

  • Never hardcode a hex value or raw Tailwind color (like bg-purple-600) in app code — use semantic tokens so themes stay swappable.
  • Dark mode is a .dark class on <html>, set from localStorage (falling back to the OS preference on first visit) by the theme-init script in app/layout.tsx before first paint. Toggle it with the control in this page's header; components built on tokens need no dark: variants.
  • Headings use font-heading; the wordmark and marketing moments use font-display; everything else inherits DM Sans from the body.
  • Spacing, radius, and shadows come from the scales on this page — no arbitrary values like p-[13px].