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.
Three ideas that every design decision should reinforce.
Music is social. Rounded shapes, warm purples, and approachable type over sharp corners and stark contrast.
One experience across every streaming service. Platform branding stays subordinate to Harmony's own language.
Sharing a playlist should feel like one gesture. Interfaces stay light — generous whitespace, few borders, clear hierarchy.
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.
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.
Mark
/branding/harmony/logo.svg
App icons, favicons, avatars, loading states
Full lockup
/branding/harmony/combined.svg
Wordmark + mark, horizontal — nav bars, marketing headers
Wordmark
/branding/harmony/wordmark.svg
Text only, no mark — tight spaces, footer credits
Reversed / mono
/branding/harmony/mono.svg
Single flat color — dark surfaces, watermarks, stamps
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
app/favicon.ico
48×48, multi-res .ico — browser tabs, bookmarks
Icon (SVG)
app/icon.svg
Scalable — modern browsers, crisp at every size
Apple touch icon
app/apple-icon.png
180×180 — iOS/iPadOS home screen, Safari
Android icon
public/icons/icon-192.png
192×192 — Android home screen
Android icon (large)
public/icons/icon-512.png
512×512 — splash screens, high-DPI displays
Maskable icon
public/icons/maskable-icon.png
512×512, ~40% safe zone — adaptive Android icon shapes
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
app/opengraph-image.png
1200×630 — link previews (iMessage, Slack, Facebook)

Twitter / X card
app/twitter-image.png
1200×675 — link previews on X
App store icon
public/icons/app-store-icon.png
1024×1024, no transparency or rounding — App Store / Play listing
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.
--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
--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
--success
#16A34A / #22C55E
Confirmations, synced states
--warning
#D97706 / #F59E0B
Caution, pending states
--destructive
#DC2626 / #EF4444
Errors, deletion
--info
#2563EB / #3B82F6
Neutral notices
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
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
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
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.
Lucide (lucide-react) is the icon library — installed and wired into components.json so shadcn components share the same set.
currentColor); use text-muted-foreground for decorative icons.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.
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:
Page conventions
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.requireProfile() (lib/supabase/require-user.ts) — signed-out visitors land on the landing page, username-less ones on /create-account.docs/branding/ for the rules this satisfies.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
Icon-to-text gaps, tight inline spacing
2
8px
Gaps inside small components (badges, chips)
3
12px
Input padding, list item gaps
4
16px
Card padding, default element gap
6
24px
Between related blocks
8
32px
Between distinct components
12
48px
Section padding
16
64px
Between page sections
24
96px
Hero spacing, page-level breathing room
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.
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
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
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.
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
ease-out
Entering — arrives quickly, settles gently
ease-in
Leaving — gets out of the way
ease-in-out
Moving or resizing in place
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.
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.
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
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.
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.
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.
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.
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
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.
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
The success-token badge is the established “connected” indicator for a linked streaming service.
Avatar
components/ui/avatar.tsx
Fallback initials sit on the secondary tint in the display font.
Card
components/ui/card.tsx
Tabs
components/ui/tabs.tsx
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
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.
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
bg-purple-600) in app code — use semantic tokens so themes stay swappable..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.font-heading; the wordmark and marketing moments use font-display; everything else inherits DM Sans from the body.p-[13px].