Voice, icons & motion
The non-token decisions that still need a system. Copy that sounds like the same product across surfaces, icons that don't fight each other, motion that earns its frame budget.
Content & copy
Voice
Funny. Sarcastic. Quietly grumpy. Friendly underneath all of that. The voice you'd use telling a friend why their staging environment is on fire — not the voice a product manager dictates into a Notion doc.
This is a personal portfolio system. The writing is half the reason anyone would pick it over the seventeenth identical shadcn template on the internet. Use it.
The five rules
- Aim the sarcasm at the situation, not the user. Roast the spinner. Roast distributed systems. Roast whoever named their project
Untitled-3. Don't roast the human reading the screen — they chose your product, that's already a kindness. - Clarity wins, always. A joke that hides what happened or what to do next is a bug. Write the working sentence first, then see if the leftover syllables want comedy.
- No apologies. No “Sorry,” no “We're working hard to fix this,” no exclamation points. Apology copy reads as insecure; this product isn't.
- Save it for the loud moments. Empty states, errors, confirmations, the hero, the 404, the splash text. The settings panel can stay boring — should, even. If every label is a punchline, none of them are.
- No dad humor. No “Whoopsie,” no Houston references, no “loading awesomeness,” no pop culture that ages in a calendar year. Smart-funny, not desperate-funny. If a copywriter at a SaaS company would high-five it, delete it.
What it sounds like
Don't
“Oops! Something went wrong. We're sorry for the inconvenience.”
“Welcome to the future of productivity!”
“Loading awesomeness…”
“Houston, we have a problem 🚀”
Do
“Server didn't answer. It gets like this sometimes — try again.”
“A design system, built because the others kept making me angry.”
“One sec — pretending to do math.”
“Couldn't reach the server. This is fine. Probably.”
Casing
- Sentence case for buttons, headings, labels, menu items, table headers — basically everything except product names. “Save changes,” not “Save Changes.”
- Title Case only for proper nouns (product names, feature names that function as products).
- No ALL CAPS outside of brand wordmarks and unit labels (KB, MB, GMT). Phosphor weight + tracking carries the same emphasis without screaming.
Button labels
- Verb-led, terse. “Save,” “Cancel,” “Add member,” “Invite teammate.” Not “Submit,” “OK,” “Members,” “Click here.”
- Object after verb when ambiguous. “Add” alone is fine in a member list (the object is implied); “Add” alone next to four other “Add” buttons is not — make it “Add member.”
- Destructive uses the verb directly. “Delete project,” not “Confirm.” The user should be able to read the button alone and know what will happen.
Error messages
Two parts, in this order: what happened + how to fix it. The voice is the third ingredient — slip it in after those two are clear, never instead of them.
Don't
“Something went wrong.” (too vague)
“Whoopsie!” (try-hard)
“Oh no, looks like you broke something.” (blames the user)
“Error 500.” (useless)
Do
“Session expired. Sign in and we'll act like nothing happened.”
“Email's already taken. Sign in or pick a new one.”
“Server's not answering. It does this sometimes — try again.”
“That file's too big. Trim it under 10MB or summon more cloud.”
Empty states
Two parts: what's missing + concrete next action. Empty states are prime real estate for the voice — the user has nothing to read, so they'll actually read this.
- “No projects yet. The first one's always the hardest.” → button: “Create project”
- “No results for ‘synergy’. Try fewer adjectives.” → button: “Clear filters”
- “Inbox zero. You've achieved what most can only dream of.” → (maybe no button — let them have the moment)
- “No team members. It's lonely at the top.” → button: “Invite teammate”
Confirmation & success
Brief, past tense, optional shrug. Toasts prefer ≤ 8 words. Don't high-five the user — it's saving a form, not curing cancer.
- “Saved. You're welcome.”
- “Created. Try not to delete it immediately.”
- “Invite sent. They'll either thank you or block you.”
- “Done. That was easy.”
- “Published. Live and irreversible.”
Loading states
- Skeleton for content that has a known shape (a list of cards, a table row). Always preferred over spinners for content.
- Spinneronly for ≤ 2s actions (button submission, modal close after save). If it's longer, swap to skeleton or progress.
- Progress when you can quote duration or percentage (uploads, imports, multi-step forms).
Numbers, dates, units
- Numbers: thousand-separator on counts ≥ 10,000 (
1,234not1234when shown as a count;1234is fine for IDs). - Dates: relative for < 7 days (“2h ago”), absolute after.
- Times: 24h in dashboards / dev tooling, 12h in consumer products. Pick once per product.
- Units: space before (
3 KB), no plural form (1 KB, not1KBs).
Iconography
Kaotypr uses Phosphor as its icon set. Sizing is tokenized via --size-icon-*; weight is a stylistic decision with rules.
Phosphor weights
- Regular (default) — most icons in your UI. Toolbar, sidebar, inline icons.
- Duotone— active / current / emphasized states. Kaotypr uses it on the active sidebar item, current step in a wizard, and in headlines (e.g. the foundation page's tier-card icons).
- Fill — status icons (always paired with a status surface), and affordances that need to read at a glance from far away (close buttons in modals, drag handles).
- Bold / Light / Thin — avoid. They break the visual rhythm with the rest of the system.
Sizing
size-(--size-icon-sm)— inline with body text, list rows, sidebar navsize-(--size-icon-md)— toolbar buttons, table actions, badgessize-(--size-icon-lg)— empty-state illustration, page-level affordance
Icon + label vs icon-only
Don't
Icon-only buttons without aria-label. Screen readers announce nothing.
Two icons with similar silhouettes side-by-side (e.g. Pencil + Note). Pick one.
Icon + label where the icon is purely decorative for verbose actions (“View detailed report” — the icon adds noise, not signal).
Do
Icon-only with aria-label="Close" for universal affordances (close, expand, drag).
Icon + label for primary actions in a row of competing buttons (Add, Filter, Sort, Export).
Status icons paired with a status surface, always. Never status icon alone on a neutral surface.
Server-rendered icons
For server components and SEO-critical surfaces, import from @phosphor-icons/react/ssr— these are tree-shakeable and don't bring the client runtime. Use @phosphor-icons/react only when you actually need a client component (e.g. animated weight switching).
Motion narrative
The system tokenizes how things move (durations, easings) but not when. Motion that earns its place: feedback, continuity, occasional delight. Motion that doesn't: every hover, every page render, every list item entrance.
When motion is appropriate
- Feedback — the user just did something and motion confirms the state changed (toggle flip, button press depth, save-confirmation tick). Use
duration-fast+ease-out. - Continuity— the same object exists before and after a layout change, motion preserves the user's mental model (item flying to a list, modal expanding from its trigger). Use
duration-normal+ease-springor layout animation. - Hierarchy reveal — content arrives in a logical order (hero → feature → CTA). Stagger ≤ 0.07s per item,
duration-normal+ease-out. - Delight — sparingly, on confirmation moments only (first deploy, completed onboarding). Use
duration-slower+ease-overshoot.
When motion is NOT appropriate
- On every hover. Tailwind
transition-colorsalready handles hover state in 150ms; you don't needmotion.div. - On content arrival on every page load. Animating cards in once is a hero pattern; doing it every render is a tax.
- On scrollfor things that aren't storytelling (parallax in docs, decorative fade-ins on settings pages). Scroll-linked motion is for landing pages.
- In dialogs / sheets / popovers— these already have correct enter/exit via shadcn + tw-animate-css. Don't double-animate.
Performance budget
- Animate
transformandopacity. Avoidwidth,height,top,leftunless using Motion'slayoutprop (which translates them to transforms for you). - Avoid
transition-all. Be specific:transition-colors,transition-transform,transition-opacity. - Stagger budget: ≤ 0.07s per item for lists, ≤ 0.1s for hero reveals. Beyond ~0.15s the animation feels sluggish.
Reduced motion
Every animation longer than ~150ms must respect prefers-reduced-motion. Two paths:
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="motion-reduce:!duration-(--duration-instant)"
/>