Daily Writing Friends Design System
Follow these guidelines for ALL UI-related work in this project.
Design Documentation
For detailed reference, see the design docs:
- tokens.md - Colors, typography, spacing
- buttons.md - Button hierarchy and usage
- components.md - Cards, inputs, interactions
- theme.md - Dark mode, accessibility, mobile
- motion.md - View transitions, content reveals, easing
Quick Reference
Color System (CSS Variables)
/* 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%);
Button Hierarchy (Most to Least Important)
| 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>
Ghost Button Override Pattern
When ghost buttons need consistent styling on hover:
<Button
variant="ghost"
className="text-foreground hover:bg-transparent hover:text-foreground"
>
Component Styling
// 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">
Utility Classes
| 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) |
Dark Mode
- Strategy: Tailwind
darkMode: 'class' - Toggle:
useTheme()hook from@/shared/hooks/useTheme - Persistence: localStorage with OS preference fallback
import { useTheme } from '@/shared/hooks/useTheme';
const { theme, toggleTheme } = useTheme();
Spacing
- Major sections:
my-6/py-6 - Minor sections:
my-3/py-3 - Default:
space-y-4,p-4 - Mobile:
px-3 md:px-4
Accessibility
- Touch targets: minimum 44px (
size-11/h-11) by default; 36px (size-9/h-9) allowed for dense UI where space is constrained - Color contrast: 4.5:1 for text, 3:1 for large text
- Focus visibility: use
reading-focus - Screen reader: use
sr-onlyfor hidden text
UI Polish Baseline
These rules are mandatory for all UI work. They prevent the most common issues that make interfaces feel off.
Never use transition-all
Always 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"
Minimum 36px touch targets
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'>
Tabular numbers on dynamic counts
Any number that changes dynamically must use tabular-nums to prevent layout shift.
<span className="tabular-nums">{count}</span>
Image outlines
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"
Concentric border radius
When nesting rounded elements, outer radius = inner radius + padding. Mismatched radii is the #1 thing that makes UIs feel off.
Shadows over borders for major surfaces
Use layered box-shadow instead of hard borders for major surface dividers (nav bars, toolbars). Borders are fine for content separators (border-border/50).
Press feedback on buttons
All buttons get active:scale-[0.96] via the base Button component. Cards and list items use active:scale-[0.99].
Text wrapping
- Headings:
text-wrap: balance(Tailwind:text-balance) - Body text:
text-wrap: pretty(Tailwind:text-pretty)
Motion & Transitions
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.
Design tokens
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 |
When to animate
| 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" |
Rules
- Direction conveys hierarchy. Forward slides right-to-left, back slides left-to-right. Never apply directional slides to sibling navigation — they falsely imply depth.
- Animate the moment of change, not the container. If a wrapper renders synchronously while its data loads asynchronously, animate the data — not the wrapper.
- Never set
view-transition-nameon text. The browser captures the element as a bitmap and scales bitmaps blurrily. Animate surfaces and backgrounds, not glyphs. - One easing curve per app. Multiple curves feel chaotic; one curve feels intentional.
- Reduced-motion is the floor. The universal
*rule inindex.csscovers most cases — verify each new animation by emulatingprefers-reduced-motion: reducein DevTools. - Gate hover transforms on touch devices. Wrap any
:hovertransform in@media (hover: hover) and (pointer: fine)so taps don't fire false hover states. - Asymmetric press and release. Slow when the user is deciding (e.g., hold-to-delete); fast when the system is responding.
Implementation
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.
Principles
- Premium minimal - Less visual noise, Bear app style
- Content-first - Remove decorative wrappers
- Consistent hierarchy - Follow button/color hierarchy strictly
- Dual-mode - All UI must work in both light and dark modes
- Mobile-first - Responsive spacing and touch targets
- Polish baseline - Follow UI Polish Baseline rules above on every change
- Native motion - Apply Motion & Transitions rules: restraint, single-purpose, one easing curve, never animate text