Duolingo
MIT
Vibrant, playful design system with bright green accents and light blue surfaces, built for engaging educational and language-learning products
Colour (34)
color.navbgvar(--duolingo-bg-white)
color.btnprimarybgvar(--duolingo-accent)
color.brandsurface1rgb(221, 244, 255)
color.brandsurface2rgb(88, 204, 2)
color.brandsurface3rgb(16, 15, 62)
color.duolingobgapprgb(16, 15, 62)
color.btnsecondarybgtransparent
color.duolingoaccentrgb(88, 204, 2)
color.primitiveblackrgb(0, 0, 0)
color.primitivewhitergb(255, 255, 255)
color.duolingobgwhitevar(--primitive-white)
color.duolingotextnavrgb(60, 60, 60)
color.primitiveblue50rgb(221, 244, 255)
color.primitivegrey50rgb(86, 86, 86)
color.duolingotextbodyrgb(119, 119, 119)
color.primitiveblue900rgb(16, 15, 62)
color.primitivegrey200rgb(136, 136, 136)
color.primitivegrey500rgb(128, 128, 128)
color.primitivegrey600rgb(60, 60, 60)
color.primitivegrey700rgb(119, 119, 119)
color.primitivegrey900rgb(75, 75, 75)
color.btnprimarybghovervar(--duolingo-accent-hover)
color.duolingobgsurfacergb(221, 244, 255)
color.duolingofocusringrgb(0, 99, 155)
color.primitivegreen500rgb(88, 204, 2)
color.primitivegreen600rgb(104, 182, 49)
color.duolingoelevation1rgb(128, 128, 128) 0px 0px 5px 0px
color.primitivebluefocusrgb(0, 99, 155)
color.duolingoaccenthoverrgb(104, 182, 49)
color.duolingoborderfocus1px solid rgb(0, 0, 0)
color.duolingotextprimaryrgb(75, 75, 75)
color.primitivegooglebluergba(66, 133, 244, 0.08)
color.duolingoborderdefault1px solid rgb(136, 136, 136)
color.duolingobordertransparent1px solid transparent
Spacing (17)
spacing.duolingospace3xs14px
spacing.duolingospace2xs16px
spacing.spacexs30px
spacing.duolingospacexs30px
spacing.duolingocontainerpadding30px
spacing.spacesm70px
spacing.duolingospacesm70px
spacing.spacemd96px
spacing.duolingospacemd96px
spacing.spacelg101px
spacing.duolingospacelg101px
spacing.bpxs400px
spacing.bpsm530px
spacing.bpmd769px
spacing.bplg1024px
spacing.bpxl1280px
spacing.duolingocontainermax1280px
Radius (7)
badgeradiusvar(--duolingo-radius-sm)
btnprimaryradiusvar(--duolingo-radius-md)
btnsecondaryradiusvar(--duolingo-radius-md)
radiussm2px
duolingoradiussm2px
radiusmd12px
duolingoradiusmd12px
Shadow (3)
effect.badgeshadow0px 0px 5px 0px rgb(128,128,128)
effect.duolingoshadownonenone
effect.duolingoshadowbadgergb(128,128,128) 0px 0px 5px 0px
# layout.md — Duolingo Design System
---
## 0. Quick Reference
> Standalone — copy-paste into `CLAUDE.md` or `.cursorrules`
**Stack:** HTML/CSS + Bootstrap · Token source: reconstructed-from-computed (0 native CSS vars) · All tokens synthesised from computed styles.
**How to apply:** Use as `var(--duolingo-token-name)` in CSS, `style={{ prop: 'var(--duolingo-token-name)' }}` in JSX, or `bg-[var(--duolingo-token-name)]` in Tailwind.
```css
:root {
/* Colours */
--duolingo-accent: rgb(88, 204, 2); /* Duolingo green — primary CTA bg, h2 colour */
--duolingo-bg-surface: rgb(221, 244, 255); /* Light blue tint — page/section surface */
--duolingo-bg-app: rgb(16, 15, 62); /* Deep navy — dark section bg, button text */
--duolingo-text-primary: rgb(75, 75, 75); /* h1 body copy */
--duolingo-text-body: rgb(119, 119, 119); /* p body copy */
--duolingo-text-nav: rgb(60, 60, 60); /* nav items, badge text */
/* Typography — din-round is the brand typeface, feather for display h2 */
--duolingo-font-primary: 'din-round', sans-serif;
--duolingo-font-display: 'feather', sans-serif;
/* Radius */
--duolingo-radius-sm: 2px; /* badges, micro elements */
--duolingo-radius-md: 12px; /* buttons, cards */
/* Spacing (use these tokens — do NOT invent intermediate values) */
--duolingo-space-xs: 30px;
--duolingo-space-sm: 70px;
--duolingo-space-md: 96px;
--duolingo-space-lg: 101px;
/* Motion */
--duolingo-duration-fast: 0.2s;
--duolingo-ease-default: ease;
}
```
```tsx
// Primary CTA Button — correct token usage
<button
style={{
fontFamily: 'var(--duolingo-font-primary)',
fontSize: '15px',
fontWeight: 700,
letterSpacing: '0.8px',
textTransform: 'uppercase',
color: 'var(--duolingo-bg-app)',
backgroundColor: 'var(--duolingo-accent)',
borderRadius: 'var(--duolingo-radius-md)',
padding: '14px 24px',
transition: `background-color var(--duolingo-duration-fast) var(--duolingo-ease-default)`,
border: 'none',
cursor: 'pointer',
}}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'rgb(104, 182, 49)')}
onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'var(--duolingo-accent)')}
>
Get Started
</button>
```
**Critical prohibitions:**
- **NEVER** use any font other than `din-round` (body/UI) or `feather` (display h2).
- **NEVER** use `border-radius` values other than `2px` or `12px`.
- **NEVER** hardcode hex/rgb colours — always use `var(--duolingo-*)` tokens.
- **NEVER** use `font-weight: 400` — the scale is `500` (regular) and `700` (bold) only.
- **NEVER** generate spacing values outside the defined `--duolingo-space-*` scale.
- **NEVER** use warm colours (red, orange, yellow) for primary surfaces or CTAs.
- **NEVER** use `Inter`, `Roboto`, or `Arial` as fallbacks — use `sans-serif` only.
**Full design system → see layout.md**
---
## 1. Design Direction & Philosophy
### Character & Aesthetic Intent
Duolingo's design is **playful-serious**: it uses bright, saturated green (`rgb(88, 204, 2)`) as its energising accent against a crisp light-blue surface, communicating approachability and optimism without sacrificing legibility. The deep navy (`rgb(16, 15, 62)`) grounds the palette and prevents the design from feeling juvenile.
Typography is opinionated and brand-owned: `din-round` (a rounded geometric sans) for all UI and body copy, and `feather` exclusively for hero/section headlines (`h2`). Both are custom webfonts loaded with `font-display: swap` — no system font substitution is acceptable.
### Mood
Encouraging, gamified, clean. The UI rewards the user. Whitespace is generous (section gaps of 70–101px). Text hierarchy is strict: only two font weights exist (500 and 700), which forces clarity through size contrast rather than weight variety.
### What This Design Explicitly Rejects
- **Warm palettes** — no reds, oranges, or yellows in primary surfaces.
- **Excessive curvature** — buttons are `12px` radius (rounded rectangle), NOT pill-shaped. Badges are `2px` (nearly sharp). Nothing in between.
- **Dense layouts** — section spacing is always ≥ 70px. Cramped UIs break the Duolingo feel.
- **Generic typography** — `Inter`, `Roboto`, `Arial`, or any system font is forbidden.
- **Muted or desaturated CTAs** — the accent green must remain fully saturated.
- **Dark-mode inversion by default** — the primary surface is light blue, not dark.
---
## 2. Colour System
### Tier 1 — Primitives
```css
:root {
/* Green family */
--primitive-green-500: rgb(88, 204, 2); /* brand green — Duolingo signature */
--primitive-green-600: rgb(104, 182, 49); /* darkened green — hover state for green CTAs */
/* Blue family */
--primitive-blue-50: rgb(221, 244, 255); /* lightest blue — page surface */
--primitive-blue-900: rgb(16, 15, 62); /* near-black navy — app dark bg */
--primitive-blue-focus: rgb(0, 99, 155); /* accessible focus ring blue */
/* Neutral/grey family */
--primitive-grey-900: rgb(75, 75, 75); /* darkest text — h1 */
--primitive-grey-700: rgb(119, 119, 119); /* body text */
--primitive-grey-600: rgb(60, 60, 60); /* nav, badge text */
--primitive-grey-500: rgb(128, 128, 128); /* shadow colour */
--primitive-grey-200: rgb(136, 136, 136); /* border on hover states */
--primitive-grey-50: rgb(86, 86, 86); /* muted link hover */
/* White / transparent */
--primitive-white: rgb(255, 255, 255);
--primitive-black: rgb(0, 0, 0);
/* Interactive (Google SSO ripple — third-party, not brand) */
--primitive-google-blue: rgba(66, 133, 244, 0.08); /* Google button hover overlay */
}
```
### Tier 2 — Semantic Aliases
```css
:root {
/* Surfaces */
--duolingo-bg-surface: var(--primitive-blue-50); /* main page/section background — reconstructed: high confidence */
--duolingo-bg-app: var(--primitive-blue-900); /* dark sections, button text overlay — reconstructed: low confidence (1 element) */
--duolingo-bg-white: var(--primitive-white); /* card and modal backgrounds */
/* Brand accent */
--duolingo-accent: var(--primitive-green-500); /* primary CTA background, h2 text — reconstructed: high confidence */
--duolingo-accent-hover: var(--primitive-green-600); /* CTA on hover — reconstructed: moderate confidence, inferred from #ot-sdk-btn hover */
/* Text */
--duolingo-text-primary: var(--primitive-grey-900); /* h1 headings — reconstructed: high confidence */
--duolingo-text-body: var(--primitive-grey-700); /* paragraph body copy — reconstructed: high confidence */
--duolingo-text-nav: var(--primitive-grey-600); /* nav links, badge labels — reconstructed: high confidence */
--duolingo-text-muted: var(--primitive-grey-500); /* secondary/placeholder text */
--duolingo-text-display: var(--duolingo-accent); /* h2 display headings use brand green */
/* Focus / Accessibility */
--duolingo-focus-ring: var(--primitive-blue-focus); /* 2px solid focus outline — reconstructed: high confidence */
/* Border */
--duolingo-border-default: var(--primitive-grey-200); /* subtle input/card borders */
}
```
### Tier 3 — Component Tokens
```css
:root {
/* Button — Primary */
--btn-primary-bg: var(--duolingo-accent);
--btn-primary-bg-hover: var(--duolingo-accent-hover);
--btn-primary-text: var(--duolingo-bg-app); /* deep navy on green */
--btn-primary-radius: var(--duolingo-radius-md); /* 12px */
/* Button — Secondary (nav "Log in" style) */
--btn-secondary-bg: transparent;
--btn-secondary-text: var(--duolingo-text-nav);
--btn-secondary-radius: var(--duolingo-radius-md);
/* Nav */
--nav-text: var(--duolingo-text-nav);
--nav-bg: var(--duolingo-bg-white);
/* Badge */
--badge-text: var(--duolingo-text-nav);
--badge-radius: var(--duolingo-radius-sm); /* 2px */
--badge-shadow: 0px 0px 5px 0px rgb(128, 128, 128);
}
```
### Colour Palette — At a Glance
| Token | Value | Usage |
|---|---|---|
| `--duolingo-accent` | `rgb(88, 204, 2)` | **Primary CTA bg, h2 text** |
| `--duolingo-bg-surface` | `rgb(221, 244, 255)` | Page surface background |
| `--duolingo-bg-app` | `rgb(16, 15, 62)` | Dark sections, CTA text |
| `--duolingo-text-primary` | `rgb(75, 75, 75)` | H1 headings |
| `--duolingo-text-body` | `rgb(119, 119, 119)` | Body paragraphs |
| `--duolingo-text-nav` | `rgb(60, 60, 60)` | Nav links, badges |
| `--duolingo-accent-hover` | `rgb(104, 182, 49)` | CTA hover state |
| `--duolingo-focus-ring` | `rgb(0, 99, 155)` | 2px focus outline |
---
## 3. Typography System
### Fonts
```css
@font-face {
font-family: 'din-round';
font-weight: 400; /* used for 500 and 700 — single file, weight simulated */
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'feather';
font-weight: 400;
font-style: normal;
font-display: swap;
}
```
> **`din-round`** is the universal UI font. **`feather`** is reserved exclusively for `h2` display headlines. NEVER swap them.
### Composite Typography Tokens
```css
:root {
/* ── Display / Hero ── */
/* Used for h1 on marketing hero sections (64px variant) */
--type-hero: {
font-family: 'din-round', sans-serif;
font-size: 64px; /* --duolingo-font-size-2xl */
font-weight: 700; /* --duolingo-font-weight-medium */
line-height: normal;
letter-spacing: normal;
color: var(--duolingo-text-primary);
}
/* ── Section Headline ── */
/* Used for h1 in secondary hero blocks (32px) */
--type-h1: {
font-family: 'din-round', sans-serif;
font-size: 32px; /* --duolingo-font-size-lg */
font-weight: 700;
line-height: normal;
letter-spacing: normal;
color: var(--duolingo-text-primary);
text-align: center;
}
/* ── Display Accent Headline ── */
/* h2 — feather font, brand green, used for section titles */
--type-h2: {
font-family: 'feather', sans-serif;
font-size: 48px; /* --duolingo-font-size-xl */
font-weight: 700;
line-height: normal;
letter-spacing: normal;
color: var(--duolingo-accent); /* rgb(88, 204, 2) */
text-align: start;
}
/* ── Body Copy ── */
/* p, main content paragraphs */
--type-body: {
font-family: 'din-round', sans-serif;
font-size: 17px; /* --duolingo-font-size-md */
font-weight: 500; /* --duolingo-font-weight-regular */
line-height: 24px; /* --duolingo-line-height-loose */
letter-spacing: normal;
color: var(--duolingo-text-body);
}
/* ── UI / Navigation ── */
/* Nav links, labels, badges */
--type-ui: {
font-family: 'din-round', sans-serif;
font-size: 17px; /* --duolingo-font-size-md */
font-weight: 500;
line-height: 20px; /* --duolingo-line-height-tight */
letter-spacing: normal;
color: var(--duolingo-text-nav);
}
/* ── Button Label ── */
/* All button text */
--type-button: {
font-family: 'din-round', sans-serif;
font-size: 15px; /* --duolingo-font-size-sm */
font-weight: 700;
line-height: normal;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--duolingo-bg-app);
}
/* ── Caption / App Store labels ── */
/* Small labels like "Download on the" */
--type-caption: {
font-family: 'din-round', sans-serif;
font-size: 14px; /* --duolingo-font-size-xs */
font-weight: 500;
line-height: 20px; /* --duolingo-line-height-tight */
letter-spacing: normal;
}
}
```
### Typography Scale Summary
| Token | Family | Size | Weight | Line-height | Usage |
|---|---|---|---|---|---|
| `--type-hero` | din-round | **64px** | 700 | normal | Full-screen hero h1 |
| `--type-h1` | din-round | **32px** | 700 | normal | Section h1, centered |
| `--type-h2` | feather | **48px** | 700 | normal | Section titles, green |
| `--type-body` | din-round | 17px | 500 | 24px | Body paragraphs |
| `--type-ui` | din-round | 17px | 500 | 20px | Nav, badges, labels |
| `--type-button` | din-round | 15px | 700 | normal | Buttons, uppercase |
| `--type-caption` | din-round | 14px | 500 | 20px | App store, fine print |
### Font Weight Scale
| Token | Value | Usage |
|---|---|---|
| `--duolingo-font-weight-regular` | `500` | Body, UI, nav, captions |
| `--duolingo-font-weight-medium` | `700` | All headings, all buttons |
> **No `400` weight exists in this system.** The lightest weight is `500`.
---
## 4. Spacing & Layout
### Spacing Scale
```css
:root {
/* ── Spacing Tokens ── */
/* NOTE: These are extracted values — they do not follow a strict 4px grid.
30px ≈ 8×4 (rounded down), 70px ≈ 17×4 (nearest: 72px), 96px = 24×4, 101px ≈ 25×4.
Use tokens exactly as defined — do NOT round to 4px grid without design approval. */
--duolingo-space-xs: 30px; /* compact gap — internal card padding, small component spacing */
--duolingo-space-sm: 70px; /* section sub-gap — space between elements within a section */
--duolingo-space-md: 96px; /* section gap — vertical spacing between page sections */
--duolingo-space-lg: 101px; /* hero gap — largest vertical block separation */
/* Derived micro-spacing (inferred from button padding) */
--duolingo-space-2xs: 16px; /* button horizontal padding (extracted from button_primary: 0px 16px) */
--duolingo-space-3xs: 14px; /* button vertical padding (reconstructed: moderate confidence) */
}
```
### Grid System
```css
:root {
/* Container */
--duolingo-container-max: 1280px; /* inferred from breakpoint ceiling — reconstructed: moderate confidence */
--duolingo-container-padding: 30px; /* sides — aligns with --duolingo-space-xs */
/* Breakpoints — extracted from media queries */
--bp-xs: 400px; /* small phones */
--bp-sm: 530px; /* mid phones */
--bp-md: 769px; /* tablet portrait */
--bp-lg: 1024px; /* tablet landscape / small desktop */
--bp-xl: 1280px; /* large desktop */
/* Notable intermediate breakpoints (layout-specific, not semantic): */
/* 425px, 426px, 550px, 600px, 890px, 896px, 897px, 1023px */
}
```
### Layout Principles
```css
/* Navigation layout — extracted from role_navigation computed styles */
nav {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 0 226px; /* desktop centering — reconstructed: moderate confidence */
}
/* Primary button layout — extracted from button_primary computed styles */
button {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0 16px;
}
```
**Flex vs Grid decision rule:**
- Use **flex row + `justify-content: space-between`** for navigation and horizontal utility bars.
- Use **flex column** for stacked content within sections.
- Use **CSS grid** for multi-column feature or card layouts (column ratios TBD — no screenshot data available).
---
## 5. Page Structure & Layout Patterns
> Token source: layout digest (no screenshots). Inferred sections marked "(inferred)". Computed values anchored to extracted styles.
### 5.1 Section Map
| # | Section | Layout Type | Est. Height | Key Elements | Confidence |
|---|---|---|---|---|---|
| 1 | **Navigation / Header** | Flex row, space-between | ~80px | Logo, nav links, "Log in" + "Get started" CTAs | Extracted |
| 2 | **Hero** | Centered column | ~600px | H1 (64px, din-round), subhead (17px body), primary CTA (green), secondary CTA | Inferred |
| 3 | **Feature / Value Props** | Multi-column grid | ~500px | H2 (feather, green), body paragraphs, supporting imagery | Inferred |
| 4 | **Science / Methodology** | Alternating text+image | ~400px | H2, body copy, stat callouts | Inferred |
| 5 | **Motivation / Streak** | Full-width panel | ~400px | H2 ("stay motivated"), dark navy bg, illustration | Inferred |
| 6 | **App Download** | Flex row | ~300px | App store badges (14px caption), QR code or device mockup | Inferred |
| 7 | **Footer** | Multi-column + baseline | ~300px | Nav links (17px UI), legal, social icons | Inferred |
### 5.2 Layout Patterns
**Navigation:**
```
[Logo] [nav link] [nav link] [nav link] [nav link] [Log in] [Get Started ▶]
← justify-content: space-between | margin: 0 226px (desktop) | align-items: center →
```
- The nav is a flex row with `justify-content: space-between`. The logo sits left, the CTA buttons sit right.
- CTA "Get Started" uses `--duolingo-accent` background, `--duolingo-bg-app` text, `border-radius: 12px`.
**Hero Section (inferred):**
- Single centered column, `text-align: center` (confirmed on h1 computed style).
- H1 at `64px` / `700` weight in `din-round`, followed by 17px body paragraph.
- Primary CTA button: green (`rgb(88, 204, 2)`) background, navy text, `12px` radius, uppercase `15px` label.
- Vertical gap between hero elements: `--duolingo-space-xs` (30px).
**Feature Sections (inferred):**
- H2 in `feather`, `48px`, `rgb(88, 204, 2)`, `text-align: start` — confirmed left-aligned.
- Body copy `17px` / `500` weight in `din-round`, `rgb(119, 119, 119)`.
- Section vertical spacing: `--duolingo-space-md` (96px) between sections, `--duolingo-space-sm` (70px) between elements within a section.
**Dark Section (inferred from `--duolingo-bg-app`):**
- Background: `rgb(16, 15, 62)` (deep navy). Only 1 element uses this colour — likely a full-width highlight band.
- Text likely inverts to white — `[TBD - extract manually]`.
### 5.3 Visual Hierarchy
- **Most prominent element:** H2 headings in `feather` + Duolingo green — they command attention before body copy.
- **CTA placement:** Top-right (nav) + center-hero. Primary CTA is always the green button.
- **Whitespace rhythm:** Generous — 96–101px between major sections, 70px within sections, 30px for compact internal gaps.
- **Colour as hierarchy:** Green = action/brand emphasis. Navy = structural depth. Light blue = surface rest state.
### 5.4 Content Patterns
1. **Headline → Body → CTA:** Every section leads with H2 (green, feather), followed by 17px body copy, then an action element (button or link).
2. **Left-aligned sections:** H2 `text-align: start` — sections are NOT centered-headline layouts (only h1 is `text-align: center`).
3. **App store badges:** Consistently use `--type-caption` (14px, din-round) for "Download on the" / "Get it on" labels.
4. **Nav CTAs:** Two buttons always present — secondary (outline/ghost style) and primary (green filled). Both use `border-radius: 12px`.
---
## 6. Component Patterns
### 6.1 Primary Button
**Anatomy:** `[button] → [uppercase text label]`
**Token-to-property mapping:**
| State | Background | Text | Border | Shadow | Cursor |
|---|---|---|---|---|---|
| Default | `--duolingo-accent` (`rgb(88,204,2)`) | `--duolingo-bg-app` | none | none | pointer |
| Hover | `rgb(104, 182, 49)` | `--duolingo-bg-app` | none | none | pointer |
| Focus | `--duolingo-accent` | `--duolingo-bg-app` | outline `2px solid rgb(0,99,155)` | none | pointer |
| Active | `rgb(72, 170, 1)` (darkened) | `--duolingo-bg-app` | none | none | pointer |
| Disabled | `rgba(88,204,2,0.4)` | `rgba(16,15,62,0.4)` | none | none | not-allowed |
```tsx
// PrimaryButton.tsx — production-ready, all states
import { ButtonHTMLAttributes, forwardRef } from 'react';
interface PrimaryButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
loading?: boolean;
}
const PrimaryButton = forwardRef<HTMLButtonElement, PrimaryButtonProps>(
({ children, loading, disabled, ...props }, ref) => {
const isDisabled = disabled || loading;
return (
<button
ref={ref}
disabled={isDisabled}
{...props}
style={{
// Layout
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
padding: '14px 24px',
gap: '8px',
// Typography — --type-button composite
fontFamily: 'var(--duolingo-font-primary)',
fontSize: '15px',
fontWeight: 700,
letterSpacing: '0.8px',
textTransform: 'uppercase',
textDecoration: 'none',
// Colour
color: isDisabled
? 'rgba(16, 15, 62, 0.4)'
: 'var(--duolingo-bg-app)',
backgroundColor: isDisabled
? 'rgba(88, 204, 2, 0.4)'
: 'var(--duolingo-accent)',
// Shape
borderRadius: 'var(--duolingo-radius-md)',
border: 'none',
// Interaction
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: `background-color var(--duolingo-duration-fast) var(--duolingo-ease-default),
opacity var(--duolingo-duration-fast) var(--duolingo-ease-default)`,
...props.style,
}}
onMouseEnter={e => {
if (!isDisabled) {
e.currentTarget.style.backgroundColor = 'rgb(104, 182, 49)';
}
}}
onMouseLeave={e => {
if (!isDisabled) {
e.currentTarget.style.backgroundColor = 'var(--duolingo-accent)';
}
}}
onMouseDown={e => {
if (!isDisabled) {
e.currentTarget.style.backgroundColor = 'rgb(72, 170, 1)';
}
}}
onMouseUp={e => {
if (!isDisabled) {
e.currentTarget.style.backgroundColor = 'rgb(104, 182, 49)';
}
}}
onFocus={e => {
e.currentTarget.style.outline = '2px solid var(--duolingo-focus-ring)';
e.currentTarget.style.outlineOffset = '2px';
}}
onBlur={e => {
e.currentTarget.style.outline = 'none';
}}
>
{loading ? (
<span aria-label="Loading" role="status" style={{ opacity: 0.7 }}>
···
</span>
) : (
children
)}
</button>
);
}
);
PrimaryButton.displayName = 'PrimaryButton';
export default PrimaryButton;
```
---
### 6.2 Navigation Bar
**Anatomy:** `[nav] → [logo] + [nav-links group] + [cta-group: secondary + primary button]`
**Token-to-property mapping:**
| State | Link colour | Link decoration | CTA |
|---|---|---|---|
| Default | `--duolingo-text-nav` (`rgb(60,60,60)`) | none | Green button |
| Hover (link) | `rgb(86, 86, 86)` | none | — |
| Focus (link) | `rgb(60,60,60)` | outline `2px solid rgb(0,0,0)` | — |
| Active (link) | `rgb(40,40,40)` (inferred) | none | — |
```tsx
// NavBar.tsx
const NavBar = () => (
<nav
role="navigation"
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0 226px',
backgroundColor: 'var(--duolingo-bg-white)',
height: '80px',
fontFamily: 'var(--duolingo-font-primary)',
fontSize: '17px',
fontWeight: 500,
lineHeight: '20px',
}}
>
{/* Logo */}
<a href="/" aria-label="Duolingo home">
{/* SVG logo here */}
</a>
{/* Nav Links */}
<ul style={{ display: 'flex', gap: '32px', listStyle: 'none', margin: 0, padding: 0 }}>
{['Courses', 'Mission', 'Approach'].map(label => (
<li key={label}>
<a
href={`/${label.toLowerCase()}`}
style={{
color: 'var(--duolingo-text-nav)',
textDecoration: 'none',
fontFamily: 'var(--duolingo-font-primary)',
fontSize: '17px',
fontWeight: 500,
lineHeight: '22px',
transition: `color var(--duolingo-duration-fast) var(--duolingo-ease-default)`,
}}
onMouseEnter={e => (e.currentTarget.style.color = 'rgb(86, 86, 86)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--duolingo-text-nav)')}
>
{label}
</a>
</li>
))}
</ul>
{/* CTA Group */}
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<button
style={{
fontFamily: 'var(--duolingo-font-primary)',
fontSize: '15px',
fontWeight: 700,
letterSpacing: '0.8px',
textTransform: 'uppercase',
color: 'var(--duolingo-text-nav)',
backgroundColor: 'transparent',
border: '2px solid var(--duolingo-text-nav)',
borderRadius: 'var(--duolingo-radius-md)',
padding: '10px 20px',
cursor: 'pointer',
}}
>
Log In
</button>
<PrimaryButton>Get Started</PrimaryButton>
</div>
</nav>
);
```
---
### 6.3 Badge
**Anatomy:** `[div.badge] → [text content]`
**Token-to-property mapping:**
| State | Background | Shadow | Border |
|---|---|---|---|
| Default | transparent | `rgb(128,128,128) 0px 0px 5px 0px` | `2px` radius |
| Hover | transparent | — | — |
| Focus | outline `2px solid rgb(0,0,0)` | — | — |
```tsx
// Badge.tsx
const Badge = ({ children }: { children: React.ReactNode }) => (
<div
style={{
fontFamily: 'var(--duolingo-font-primary)',
fontSize: '17px',
fontWeight: 500,
lineHeight: '20px',
color: 'var(--duolingo-text-nav)',
borderRadius: 'var(--duolingo-radius-sm)', /* 2px — NEVER use 12px here */
boxShadow: 'var(--duolingo-shadow-badge)',
padding: '8px 12px',
display: 'inline-block',
}}
>
{children}
</div>
);
```
---
### 6.4 Text Input
**Anatomy:** `[label] + [input]`
**Token-to-property mapping:**
| State | Border | Outline | Background |
|---|---|---|---|
| Default | `1px solid rgb(136,136,136)` | none | white |
| Hover | `1px solid rgb(136,136,136)` | none | white |
| Focus | `1px solid rgb(0,0,0)` | none (outline `0px`) | white |
| Disabled | — | none | `rgba(0,0,0,0.05)` (inferred) |
| Error | `[TBD - extract manually]` | — | — |
```tsx
// TextInput.tsx
const TextInput = ({
label,
disabled,
error,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label
style={{
fontFamily: 'var(--duolingo-font-primary)',
fontSize: '15px',
fontWeight: 700,
color: 'var(--duolingo-text-nav)',
}}
>
{label}
</label>
<input
disabled={disabled}
{...props}
style={{
fontFamily: 'var(--duolingo-font-primary)',
fontSize: '17px',
fontWeight: 500,
color: disabled ? 'var(--duolingo-text-muted)' : 'var(--duolingo-text-primary)',
backgroundColor: disabled ? 'rgba(0,0,0,0.05)' : 'var(--duolingo-bg-white)',
border: error
? '1px solid rgb(220, 38, 38)'
: '1px solid var(--duolingo-border-default)',
borderRadius: 'var(--duolingo-radius-md)',
padding: '12px 16px',
outline: 'none',
transition: `border-color var(--duolingo-duration-fast) var(--duolingo-ease-default)`,
cursor: disabled ? 'not-allowed' : 'text',
opacity: disabled ? 0.7 : 1,
...props.style,
}}
onFocus={e => {
e.currentTarget.style.borderColor = 'rgb(0, 0, 0)';
}}
onBlur={e => {
e.currentTarget.style.borderColor = error
? 'rgb(220, 38, 38)'
: 'var(--duolingo-border-default)';
}}
/>
{error && (
<span
style={{
fontFamily: 'var(--duolingo-font-primary)',
fontSize: '14px',
fontWeight: 500,
color: 'rgb(220, 38, 38)',
}}
>
{error}
</span>
)}
</div>
);
```
---
## 7. Elevation & Depth
```css
:root {
/* Shadow tokens */
--duolingo-shadow-badge: rgb(128, 128, 128) 0px 0px 5px 0px;
/* Used on: badge elements. Soft ambient glow — not directional. */
--duolingo-shadow-none: none;
/* Used on: buttons, nav, body, headings — flat design by default. */
/* Elevation scale (reconstructed: moderate confidence — only 1 shadow value found) */
--duolingo-elevation-0: none; /* flush with surface */
--duolingo-elevation-1: rgb(128, 128, 128) 0px 0px 5px 0px; /* badge, soft float */
--duolingo-elevation-2: [TBD - extract manually]; /* card/modal — not yet extracted */
/* Border tokens */
--duolingo-border-default: 1px solid rgb(136, 136, 136); /* input default */
--duolingo-border-focus: 1px solid rgb(0, 0, 0); /* input focus */
--duolingo-border-transparent: 1px solid transparent; /* removes border visually */
/* Z-index scale (reconstructed: moderate confidence) */
--z-base: 0; /* page content */
--z-badge: 10; /* floating badge */
--z-nav: 100; /* sticky navigation */
--z-modal: 200; /* modals, cookie banners */
--z-toast: 300; /* toasts / system alerts */
}
```
**Layering principles:**
- The UI is predominantly **flat** — most elements have `box-shadow: none`.
- Depth is communicated through **colour contrast** (navy vs light blue), not shadows.
- Shadows appear only on floating/overlapping elements (badges, modals).
---
## 8. Motion
```css
:root {
/* Duration tokens */
--duolingo-duration-fast: 0.2s; /* standard micro-interaction (buttons, links, badge slide) */
--duolingo-duration-medium: 0.3s; /* badge position transition (extracted: `transition: right 0.3s`) */
--duolingo-duration-slow: [TBD - extract manually]; /* page-level transitions — not extracted */
/* Easing tokens */
--duolingo-ease-default: ease; /* applied to 66 elements — universal easing */
--duolingo-ease-linear: linear; /* [TBD - extract manually] */
/* Composite motion tokens */
--duolingo-transition-button: background-color var(--duolingo-duration-fast) var(--duolingo-ease-default),
opacity var(--duolingo-duration-fast) var(--duolingo-ease-default);
--duolingo-transition-badge: right var(--duolingo-duration-medium) var(--duolingo-ease-default);
--duolingo-transition-link: color var(--duolingo-duration-fast) var(--duolingo-ease-default);
}
```
### When to Animate
| Trigger | Duration | Properties | Token |
|---|---|---|---|
| Button hover/active | `0.2s` | `background-color` | `--duolingo-transition-button` |
| Link hover | `0.2s` | `color` | `--duolingo-transition-link` |
| Badge position (slide-in) | `0.3s` | `right` | `--duolingo-transition-badge` |
| Focus ring appear | `0.2s` | `outline` | `--duolingo-transition-button` |
### When NOT to Animate
- **Layout shifts** — do not animate `width`, `height`, or `margin` changes (causes reflow jank).
- **Page navigation** — no route-transition animations (not part of this design system).
- **Gamification animations** — streak/XP celebrations are feature-level, not system-level; handle separately from these tokens.
- **Respect `prefers-reduced-motion`:** Wrap all transitions:
```css
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}
```
---
## 9. Anti-Patterns & Constraints
**Rule 1: Never hardcode colour values inline.**
→ **Why it fails:** When an AI sees `color: rgb(88, 204, 2)` in context, it will reproduce the literal value throughout the component tree, bypassing the token system. If brand green ever shifts (e.g. accessibility audit), every hardcoded instance breaks silently.
→ **Do instead:** Always use `color: var(--duolingo-accent)`. Define the value once in `:root`.
---
**Rule 2: Never use `font-weight: 400`.**
→ **Why it fails:** AI agents default to `400` as the "normal" weight. But Duolingo's body weight is `500`, and `400` renders visibly lighter on `din-round`, creating a typographic inconsistency that looks like an incomplete render or a font-loading failure.
→ **Do instead:** Use `font-weight: 500` (`--duolingo-font-weight-regular`) for all body/UI, `700` (`--duolingo-font-weight-medium`) for headings and buttons. Never `400`.
---
**Rule 3: Never use `Inter`, `Roboto`, `Arial`, or any system font as a primary or fallback font.**
→ **Why it fails:** AI coding assistants trained on common codebases default to `Inter` or `system-ui`. Rendering `din-round` content in `Inter` collapses Duolingo's brand identity — the rounded letter forms are structurally central to the visual personality.
→ **Do instead:** Font stack is always `'din-round', sans-serif` (body/UI) or `'feather', sans-serif` (h2 display). The `sans-serif` fallback is intentional as a loading placeholder only.
---
**Rule 4: Never use `border-radius` values other than `2px` or `12px`.**
→ **Why it fails:** AI agents often apply `8px` as a "reasonable default" or `9999px` for pill shapes. Duolingo's system has exactly two radii: `2px` (badges/micro) and `12px` (buttons/cards). Any other value is off-system and visually incorrect.
→ **Do instead:** Use `var(--duolingo-radius-sm)` (2px) for small/flat elements, `var(--duolingo-radius-md)` (12px) for interactive controls and containers.
---
**Rule 5: Never construct Tailwind class names dynamically.**
→ **Why it fails:** Tailwind's JIT compiler purges classes that aren't statically detectable at build time. `bg-[${color}]` or `` `text-${size}` `` will compile to nothing — the style silently vanishes in production.
→ **Do instead:** Use `var(--duolingo-*)` tokens with inline styles or a CSS-in-JS approach for dynamic values. Static Tailwind classes (e.g. `bg-[var(--duolingo-accent)]`) are fine.
---
**Rule 6: Never omit hover, focus, and disabled states from interactive components.**
→ **Why it fails:** AI generates the `default` state only when no state table is provided. Duolingo's UI is gamified and interaction-dense — missing focus rings fail WCAG 2.1 AA, missing hover feedback breaks the energetic feel, and a greyed-out disabled button without `cursor: not-allowed` confuses users.
→ **Do instead:** Implement all five states (default, hover, focus, active, disabled) for every button and input, using the state table in Section 6.
---
**Rule 7: Never use `position: absolute` as a layout mechanism for multi-element sections.**
→ **Why it fails:** AI reaching for absolute positioning to layer text over images will break at responsive breakpoints — the positioned elements overflow or collapse since absolute elements are taken out of flow.
→ **Do instead:** Use `display: flex` with `justify-content: space-between` (nav pattern) or `display: grid` for overlapping content. Use `position: absolute` only for decorative overlays with known dimensions (e.g. mascot character overlapping a card corner).
---
**Rule 8: Never use spacing values not present in the `--duolingo-space-*` token set.**
→ **Why it fails:** 3 off-grid values were found in extraction (30px, 70px, 101px) — these are intentional design values, not accidents. When AI "normalises" them to `32px`, `72px`, `100px`, it breaks the precise whitespace rhythm that defines Duolingo's airy layout.
→ **Do instead:** Use `var(--duolingo-space-xs)` through `var(--duolingo-space-lg)` exactly. If a spacing need doesn't fit the scale, flag it for design review — don't interpolate.
---
**Rule 9: Never use `!important` to override component styles.**
→ **Why it fails:** Duolingo's Bootstrap base means `!important` chains accumulate quickly. One override triggers a cascade of counter-overrides — the codebase becomes unmaintainable and the token system loses authority.
→ **Do instead:** Increase CSS specificity with compound selectors (e.g. `button.duolingo-btn`) or use CSS Modules / CSS-in-JS scoping to isolate component styles without `!important`.
---
**Rule 10: Never apply `--duolingo-bg-app` (navy, `rgb(16, 15, 62)`) as a text colour on dark backgrounds.**
→ **Why it fails:** This navy is used as the text colour on green CTA buttons specifically. AI may reuse it as general "dark text" — but on the dark surface sections (which also use this navy as background), it produces invisible black-on-black text.
→ **Do instead:** On dark (`--duolingo-bg-app`) backgrounds, text should invert to white `rgb(255,255,255)` `[TBD - extract manually — no dark-mode text token extracted]`. Use `--duolingo-text-primary` (grey-900) only on light surfaces.
---
## Appendix A: Complete Token Reference
Every token extracted from the source. §0 CORE TOKENS is the primary AI signal; this appendix is reference material an AI can cross-check against when a curated role is missing.
```css
/* Colours (34) */
--brand-surface-1: rgb(221, 244, 255); /* Brand surface, dominant on 1 element — e.g. "div" /* mined from computed styles */ */
--brand-surface-2: rgb(88, 204, 2); /* Brand surface, dominant on 2 elements — e.g. "About usCoursesMissionApproach" /* mined from computed styles */ */
--brand-surface-3: rgb(16, 15, 62); /* Brand surface, dominant on 1 element — e.g. "Try 1 week free" /* mined from computed styles */ */
--duolingo-accent: rgb(88, 204, 2);
--duolingo-bg-surface: rgb(221, 244, 255);
--duolingo-bg-app: rgb(16, 15, 62);
--duolingo-text-primary: rgb(75, 75, 75);
--duolingo-text-body: rgb(119, 119, 119);
--duolingo-text-nav: rgb(60, 60, 60);
--primitive-green-500: rgb(88, 204, 2);
--primitive-green-600: rgb(104, 182, 49);
--primitive-blue-50: rgb(221, 244, 255);
--primitive-blue-900: rgb(16, 15, 62);
--primitive-blue-focus: rgb(0, 99, 155);
--primitive-grey-900: rgb(75, 75, 75);
--primitive-grey-700: rgb(119, 119, 119);
--primitive-grey-600: rgb(60, 60, 60);
--primitive-grey-500: rgb(128, 128, 128);
--primitive-grey-200: rgb(136, 136, 136);
--primitive-grey-50: rgb(86, 86, 86);
--primitive-white: rgb(255, 255, 255);
--primitive-black: rgb(0, 0, 0);
--primitive-google-blue: rgba(66, 133, 244, 0.08);
--duolingo-bg-white: var(--primitive-white);
--duolingo-border-default: 1px solid rgb(136, 136, 136);
--btn-primary-bg: var(--duolingo-accent);
--btn-primary-bg-hover: var(--duolingo-accent-hover);
--btn-secondary-bg: transparent;
--nav-bg: var(--duolingo-bg-white);
--duolingo-elevation-1: rgb(128, 128, 128) 0px 0px 5px 0px;
--duolingo-border-focus: 1px solid rgb(0, 0, 0);
--duolingo-border-transparent: 1px solid transparent;
--duolingo-accent-hover: rgb(104, 182, 49);
--duolingo-focus-ring: rgb(0, 99, 155);
/* Typography (30) */
--font-size-xs: 14px; /* 2 elements — e.g. span "Download on the", span "Get it on" /* mined from computed styles */ */
--font-size-sm: 15px; /* 68 elements — e.g. span "Site language: Engli", span "Get started", span "I ALREADY HAVE AN AC" /* mined from computed styles */ */
--font-size-md: 17px; /* 115 elements — e.g. p "Learning with Duolin", p "We use a combination", p "We make it easy to f" /* mined from computed styles */ */
--font-size-lg: 32px; /* 1 element — e.g. h1 "The free, fun, and e" /* mined from computed styles */ */
--font-size-xl: 48px; /* 9 elements — e.g. h2 "free. fun. effective", h2 "backed by science", h2 "stay motivated" /* mined from computed styles */ */
--font-size-2xl: 64px; /* 2 elements — e.g. h1 "learn anytime, anywh", h1 "learn a language wit" /* mined from computed styles */ */
--font-weight-regular: 500; /* 110 elements — e.g. p "Learning with Duolin", p "We use a combination", p "We make it easy to f" /* mined from computed styles */ */
--font-weight-medium: 700; /* 87 elements — e.g. h1 "The free, fun, and e", h1 "learn anytime, anywh", h1 "learn a language wit" /* mined from computed styles */ */
--line-height-tight: 20px; /* 148 elements — e.g. span "English", span "Spanish", span "French" /* mined from computed styles */ */
--line-height-normal: 22px; /* 9 elements — e.g. a "Courses", a "Mission", a "Approach" /* mined from computed styles */ */
--line-height-loose: 24px; /* 9 elements — e.g. p "Learning with Duolin", p "We use a combination", p "We make it easy to f" /* mined from computed styles */ */
--duolingo-font-primary: 'din-round', sans-serif;
--duolingo-font-display: 'feather', sans-serif;
--duolingo-text-muted: var(--primitive-grey-500);
--duolingo-text-display: var(--duolingo-accent);
--btn-primary-text: var(--duolingo-bg-app);
--btn-secondary-text: var(--duolingo-text-nav);
--nav-text: var(--duolingo-text-nav);
--badge-text: var(--duolingo-text-nav);
--duolingo-font-weight-regular: 500;
--duolingo-font-weight-medium: 700;
--duolingo-font-size-xs: 14px;
--duolingo-font-size-sm: 15px;
--duolingo-font-size-md: 17px;
--duolingo-font-size-lg: 32px;
--duolingo-font-size-xl: 48px;
--duolingo-font-size-2xl: 64px;
--duolingo-line-height-tight: 20px;
--duolingo-line-height-normal: 22px;
--duolingo-line-height-loose: 24px;
/* Spacing (17) */
--space-xs: 30px; /* 3 elements — e.g. nav .zU2RQ, nav .zU2RQ, nav .zU2RQ /* mined from computed styles */ */
--space-sm: 70px; /* 1 element — e.g. header ._39290 /* mined from computed styles */ */
--space-md: 96px; /* 4 elements — e.g. section .uU0-M, section .uU0-M, section ._3dG3I /* mined from computed styles */ */
--space-lg: 101px; /* 21 elements — e.g. section ._3k9io, section ._3k9io, section ._3k9io /* mined from computed styles */ */
--duolingo-space-xs: 30px;
--duolingo-space-sm: 70px;
--duolingo-space-md: 96px;
--duolingo-space-lg: 101px;
--duolingo-space-2xs: 16px;
--duolingo-space-3xs: 14px;
--duolingo-container-max: 1280px;
--duolingo-container-padding: 30px;
--bp-xs: 400px;
--bp-sm: 530px;
--bp-md: 769px;
--bp-lg: 1024px;
--bp-xl: 1280px;
/* Radius (7) */
--radius-sm: 2px; /* 1 element — e.g. div .grecaptcha-badge /* mined from computed styles */ */
--radius-md: 12px; /* 5 elements — e.g. button ._2V6ug "I ALREADY HAVE AN AC", button ._1rcV8 "Try 1 week free", button "REJECT ALL" /* mined from computed styles */ */
--duolingo-radius-sm: 2px;
--duolingo-radius-md: 12px;
--btn-primary-radius: var(--duolingo-radius-md);
--btn-secondary-radius: var(--duolingo-radius-md);
--badge-radius: var(--duolingo-radius-sm);
/* Effects (3) */
--badge-shadow: 0px 0px 5px 0px rgb(128,128,128);
--duolingo-shadow-badge: rgb(128,128,128) 0px 0px 5px 0px;
--duolingo-shadow-none: none;
/* Motion (2) */
--duration-fast: 0.2s; /* 1 element — e.g. button /* mined from computed styles */ */
--ease-default: ease; /* 66 elements — e.g. button, button, button /* mined from computed styles */ */
```
## Appendix B: Token Source Metadata
| Property | Value |
|---|---|
| **Token source** | `reconstructed-from-computed` |
| **Extraction method** | Computed styles from DOM elements (h1, h2, body, button, nav, badge, link) |
| **Native CSS custom properties** | **0 found** — no `--var` definitions detected in the site's stylesheets |
| **Confidence level** | Low overall; individual token confidence noted inline |
| **Detected libraries** | Bootstrap (base layout utility) |
| **Clustering method** | Colours grouped by hue family (green, blue, neutral greys); spacing grouped by extracted values (no 4px grid normalisation applied — values preserved as-extracted); radius clustered by distinct values (2px, 12px) |
| **Radius note** | No pill-shaped buttons detected (largest radius = 12px). The 2px radius is from reCAPTCHA badge (third-party element) but aligns with Duolingo's sharp micro-element aesthetic. |
| **Typography note** | `din-round` and `feather` are custom webfonts loaded via `@font-face` with `font-display: swap`. Both declared at `font-weight: 400` in the `@font-face` rule, but computed weights show `500` and `700` throughout — font files likely contain variable weight or separate files per weight not captured in extraction. |
| **Spacing note** | Values 30px, 70px, 101px do not conform to a 4px grid. Preserved exactly as extracted. Do not normalise without design sign-off. |
| **Breakpoints note** | 13 breakpoints detected — unusual density. Many (425px, 426px, 896px, 897px) are paired 1px apart, suggesting layout pivot points rather than semantic breakpoints. The 5 semantic breakpoints (400, 530, 769, 1024, 1280) are most reliable for layout decisions. |
| **Interactive states** | Most state data sourced from OneTrust (cookie consent SDK) CSS rules — **not Duolingo brand styles**. Duolingo-native button states have low extraction confidence and should be validated manually. |
| **Missing data** | Dark-section text colour (white text on `--duolingo-bg-app`) not extracted. Error state colours not confirmed. Card component not isolated. Hero image/illustration layout not confirmed. Mark all `[TBD]` fields for manual extraction. |More from the gallery
Browse all kits →You may also like

Vercel
MITClean, minimal developer-focused design system built on Geist typography with light surfaces, precise spacing, and fast interactions for modern web applications
00
lightminimaldeveloper-toolsaas

OpenAI
MITClean, minimal design system for AI-native products built on Next.js and Tailwind, featuring a light monochromatic palette with precise token-based spacing and typography
02
lightminimalsaasdeveloper-tool

Coinbase
MITClean, professional fintech design system with bright blue accents and minimal aesthetic, built for React/Next.js applications and financial platforms
00
lightminimalfintechsaas