Design system for Daily Writing Friends app...
Follow these guidelines for ALL UI-related work in this project.
For detailed reference, see the design docs:
/* Light Mode */
--background: hsl(0, 0%, 100%);
--foreground: hsl(0, 0%, 9%);
--accent: hsl(210, 100%, 50%);
/* Dark Mode */
--background: hsl(180, 4%, 12%);
--foreground: hsl(180, 3%, 92%);
--accent: hsl(210, 100%, 70%);
| Variant | Use For | Example |
|---|---|---|
cta |
Critical conversions | Signup, Join, Main FAB |
default |
Main interactions | Login, Save, Submit |
outline |
Supporting actions | Drafts, Cancel |
ghost |
Subtle actions | Edit, Navigation, Logout |
destructive |
Dangerous actions | Delete (red ghost style) |
// CTA - most important
<Button variant="cta">νμκ°μ
</Button>
// Primary - main action
<Button variant="default">κΈ μ μ₯</Button>
// Secondary - supporting
<Button variant="outline">μμ μ μ₯ κΈ</Button>
// Ghost - subtle
<Button variant="ghost">μμ </Button>
// Destructive - dangerous (ghost style with red text)
<Button variant="destructive">μμ </Button>
When ghost buttons need consistent styling on hover:
<Button
variant="ghost"
className="text-foreground hover:bg-transparent hover:text-foreground"
>
// Card
<div className="bg-card border-border/50 reading-shadow rounded-lg p-4">
// Input
<input className="bg-input border-border reading-focus" />
// Link
<a className="text-ring hover:underline">
| Class | Purpose |
|---|---|
reading-shadow |
Adaptive shadow (light/dark) |
reading-hover |
Subtle accent highlight on hover |
reading-focus |
Focus ring (2px accent) |
text-reading |
Optimized reading (line-height 1.7) |
nav-selected |
Navigation selection state |
active-scale |
Press feedback (scale 0.99) |
darkMode: 'class'useTheme() hook from @/shared/hooks/useThemeimport { useTheme } from '@/shared/hooks/useTheme';
const { theme, toggleTheme } = useTheme();
my-6 / py-6my-3 / py-3space-y-4, p-4px-3 md:px-4size-11 / h-11) by default; 36px (size-9 / h-9) allowed for dense UI where space is constrainedreading-focussr-only for hidden textThese rules are mandatory for all UI work. They prevent the most common issues that make interfaces feel off.
transition-allAlways specify exact properties. transition-all animates unrelated properties and causes jank.
// BAD
className="transition-all duration-200"
// GOOD - specify what actually changes
className="transition-transform duration-200"
className="transition-[transform,background-color] duration-200"
className="transition-colors duration-200"
Every interactive element must have at least 36Γ36px hit area (size-9). If the visible element is smaller, extend with padding.
// BAD - 24px tall
<Button size='sm' className='h-6 px-2'>
// GOOD - 36px minimum for icon buttons
<Button size='icon' className='size-9'>
Any number that changes dynamically must use tabular-nums to prevent layout shift.
<span className="tabular-nums">{count}</span>
All user-uploaded images (avatars, thumbnails) need a subtle outline to prevent bleed on matching backgrounds. Use pure black/white only β never tinted neutrals.
// On elements inside overflow-hidden containers, use ring-inset
className="ring-1 ring-inset ring-black/10 dark:ring-white/10"
// On elements without overflow clipping
className="ring-1 ring-black/10 dark:ring-white/10"
When nesting rounded elements, outer radius = inner radius + padding. Mismatched radii is the #1 thing that makes UIs feel off.
Use layered box-shadow instead of hard borders for major surface dividers (nav bars, toolbars). Borders are fine for content separators (border-border/50).
All buttons get active:scale-[0.96] via the base Button component. Cards and list items use active:scale-[0.99].
text-wrap: balance (Tailwind: text-balance)text-wrap: pretty (Tailwind: text-pretty)Animation should feel native β restrained, single-purpose, quiet. Premium iOS and Android apps don't dazzle; they confirm spatial relationships and content changes. If you can't say what an animation communicates, cut it.
Defined in :root in apps/web/src/index.css. Use these. Don't invent one-off durations.
| Token | Value | Use for |
|---|---|---|
--dwf-page-transition-duration |
280ms |
Hierarchical route changes |
--dwf-content-transition-duration |
560ms |
Async content arriving (Suspense reveals) |
--dwf-transition-easing |
cubic-bezier(0.32, 0.72, 0, 1) |
iOS-style spring; one curve everywhere |
| Pattern | Animation | Communicates |
|---|---|---|
| Hierarchical navigation (list β detail) | Directional root slide via useViewTransitionNavigate().forward() |
"Going deeper" |
| Hierarchical back (detail β list) | Opposite slide via useViewTransitionNavigate().back() |
"Going back up" |
| Suspense reveal β single block | .dwf-content-enter on the element that mounts when data is ready |
"Content arrived" |
| Suspense reveal β list | .dwf-content-stagger on the list wrapper; children cascade 40ms apart |
"Items arriving one by one" |
| Lateral navigation (tab β tab) | None | No depth to communicate |
| High-frequency actions (100+/day, keyboard shortcuts) | None | Animation slows repeated use |
| Background refresh / revalidation | None | Silent by design |
| Press feedback | active:scale-[0.96] (button) or active:scale-[0.99] (card) |
"Touch received" |
view-transition-name on text. The browser captures the element as a bitmap and scales bitmaps blurrily. Animate surfaces and backgrounds, not glyphs.* rule in index.css covers most cases β verify each new animation by emulating prefers-reduced-motion: reduce in DevTools.:hover transform in @media (hover: hover) and (pointer: fine) so taps don't fire false hover states.import { useViewTransitionNavigate } from '@/shared/navigation/useViewTransitionNavigate';
const nav = useViewTransitionNavigate();
nav.forward('/board/.../post/...'); // list β detail
nav.back(); // detail β list
For async content reveals, apply dwf-content-enter to the element that mounts the moment the data is ready β the leaf, not the parent.
See motion.md for the deeper reference.