.dark-paletteCampfire-friendly copy - tokens flow from --background-primary and --foreground-primary down to every component.
Handbook
Everything a builder needs to ship fluently with freeCodeCamp UIKit - tokens, type, motion, voice, brand, install, bundle, and how to contribute. Use the handbook navigation to jump between rules.
Foundations · 01
Navy-dark base, gold CTA, terminal semantics. The palette is driven by CSS custom properties so every surface re-themes together.
The dark palette is the source of truth. Switch the .light-palette class on
<html> and every token remaps without re-rendering any component.
--gray-00
#ffffff
Foreground base - dark mode
--gray-05
#f5f6f7
--gray-10
#dfdfe2
--gray-45
#858591
Muted body copy
--gray-75
#3b3b4f
--gray-85
#1b1b32
Secondary surfaces
--gray-90
#0a0a23
Primary surface - "dark night sky"
Every component reaches for these five surface tokens instead of raw grays. Swap a surface once and the whole kit follows.
--background-primary
var(--gray-90)
--background-secondary
var(--gray-85)
--background-tertiary
#33334f
--background-quaternary
#4b4b66
--foreground-primary
var(--gray-00)
--foreground-muted
#b0b0bd
Captions, metadata
Semantic pairs - a foreground for on-surface text and a background for solid
fills - so success, danger, warning, highlight, purple, and love
each carry their own readable on-dark / on-light variants.
--cta-background
#ffbf00
Primary CTA fill
--highlight-color
#99c9ff
Links, active nav
--success-color
#acd157
--warning-color
#f1be32
--danger-color
#ffadad
--purple-color
#dbb8ff
--love-color
#f8577c
Donate, heart affordances
Reach for the semantic token, never a raw hex. If you need a new shade, add it
to packages/uikit-css/src/tokens.css first so every surface picks it up.
.my-card {
background: var(--background-secondary);
color: var(--foreground-primary);
border: 1px solid var(--border-strong);
}
A scoped preview of both palette roots - flipping .light-palette on
<html> toggles every surface together, but you can also apply the
class to any subtree for per-region theming. Each tile below is its
own palette root.
.dark-paletteCampfire-friendly copy - tokens flow from --background-primary and --foreground-primary down to every component.
.light-paletteCampfire-friendly copy - tokens flow from --background-primary and --foreground-primary down to every component.
Foundations · 02
Lato carries body and UI copy. Hack-ZeroSlash carries code, metadata, and anything mono-spaced. Both ship with the package; no external font CDN.
Six steps - small enough to keep the system honest, wide enough to express hierarchy without ornamental sizes. Every scale step is a token so redefining the scale per palette is a one-line change.
Readable prose. Mono metadata.
curl design.freecodecamp.org/llms.txt
--font-sans: 'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Hack-ZeroSlash', 'Fira Mono', Menlo, Consolas, monospace;
0 and O never trade places in code.--lh-snug (1.33); body text uses --lh-base (1.43); call-to-action or prose paragraphs at --lh-loose (1.6).<kbd>), inline tokens, and anything you want to feel terminal-native.text-transform: uppercase + letter-spacing: 0.05em + --fs-sm + mono. Do not inline-style - reach for the .section__eyebrow utility instead.Foundations · 03
Eight-step linear scale. 4 px grid, powers of two. Compose padding, gap, and block rhythm exclusively from these tokens - no arbitrary pixel values.
--space-0
0 - zero
--space-1
4 - hairline gap
--space-2
8 - inline rhythm
--space-3
12 - inside control
--space-4
16 - card padding
--space-5
24 - block rhythm
--space-6
32 - section padding
--space-7
48 - section gap
--space-8
64 - page rhythm
--space-1 (4px) - icon gap inside a chip, hairline separator, tight badge padding.--space-2 (8px) - rhythm between inline elements (icon + label), badge paddings.--space-3 (12px) - inside-control padding (buttons, inputs, rows).--space-4 (16px) - card/panel padding, form field spacing.--space-5 (24px) - block rhythm between paragraphs, card→card gap.--space-6 (32px) - section internal padding.--space-7 (48px) - gap between major page sections.--space-8 (64px) - rhythm between distinct page regions (hero → body).gap over margin. Margin is for resetting content; gap is for laying out controls..my-card {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}Foundations · 04
A curated Lucide subset. 24x24 viewBox, 2px stroke, currentColor. One canonical source, three delivery modes - React, CSS sprite, inline body.
We do not vendor the whole set. Each wave adds only the icons an actual component needs, keeping the bundle honest.
Wave 2 expands the catalog to 60 Lucide glyphs across nine categories - arrows, feedback, object, identity, media, math, nav, editing, and a small miscellany (calendar, clock, mail, globe, filter, bell, eye, heart, star, bookmark, share). Every shipped component references from this set; nothing outside it should appear in product UI without a review.
arrow-down
arrow-left
arrow-right
arrow-up-down
arrow-up-right
arrow-up
bell
book
bookmark
calendar
check
chevron-down
chevron-left
chevron-right
chevron-up
circle-alert
circle-check
circle-x
clock
code
copy
download
equal
external-link
eye-off
eye
file-text
file
filter
folder
globe
grid
heart
home
image
info
key
loader
lock
log-out
menu
minus
pause
pencil
play
plus
search
settings
share
star
terminal
trash
triangle-alert
unlock
upload
user
users
video
x
import { Icon } from './ui/icons/Icon';
// Decorative (default) - sits next to a text label inside a button.
<Icon name="copy" size={16} />
// Meaningful - icon stands alone and needs an accessible name.
<Icon name="external-link" label="Open in new tab" />
Pure-HTML consumers reference the CSS sprite instead:
<svg width="16" height="16" aria-hidden="true">
<use href="/uikit/sprite.svg#fcc-icon-copy" />
</svg>
aria-hidden when an icon pairs with text; use label + role="img" when it stands alone.currentColor. Never set an explicit stroke override - theme the parent instead.Foundations · 05
Snap-in, ease-out, 120–260ms. No bounces, no drifts, no hover parallax. Every transition has a purpose and an honest end state.
--ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--dur-fast: 120ms;
--dur-base: 180ms;
--dur-slow: 260ms;
--dur-fast · --ease-snap
120ms · enter for tooltips, menu fades
--dur-base · --ease-snap
180ms · tabs, switches, state swaps
--dur-slow · --ease-out
260ms · dialog, drawer, page transitions
--ease-snap. Use --ease-out only for entrances (dialog, drawer, page transitions) where a longer trailing decay reads as intentional arrival.--dur-base (180ms). Micro-interactions (tooltips, hover fades) take --dur-fast. Large surfaces (dialog, drawer) take --dur-slow.--ease-snap. Exits are faster than enters so the UI feels responsive and dismissable.@media (prefers-reduced-motion: reduce) { transition: none; }. State still swaps; the animation does not.Surface | Enter | Exit | Notes |
|---|---|---|---|
| Dialog | fade 120ms + scale 0.96 → 1 260ms | reverse 140ms | Backdrop fades independently. |
| Drawer | translateX 260ms | reverse 200ms | Also translateY for bottom sheet. |
| Dropdown / Listbox | translateY(-4) + fade 120ms | reverse 100ms | No scale - menus should not “grow”. |
| Tooltip | fade 80ms after 300ms delay | fade 80ms, no delay | Delay resets per group. |
| Tabs | indicator translateX 180ms | - | Content cross-fades 120ms. |
| Switch | thumb translateX 160ms | - | No bounce. |
| Page | opacity 120ms on Astro view transitions | - | Reduced-motion honored. |
Foundations · 06
Terminal-native copy. Clear, learner-respectful, never cutesy. Command-line Chic on the page - literal where it matters, warm where it counts.
Surface | Do | Don’t |
|---|---|---|
| Primary button |
|
|
| Secondary button |
|
|
| Empty state title | No components yet | Oops! Nothing to see here 😅 |
| Error | Token expired - sign in again. | Something went wrong. |
| Success | Copied to clipboard. |
|
| Tooltip | Keyboard shortcut: ⌘K | Tap to open search |
When we want to lean into the brand, frame prose as if it’s happening in a terminal:
camp@fcc:~$ curl design.freecodecamp.org/components/button.md
camp@fcc:~$ open components
Use this sparingly - the landing hero, a README banner, a CLI-adjacent guide page. Every section heading does not need to be a prompt.
Intl.* - never hand-rolled.Foundations · 07
What this handbook covers, who it is for, and the design language in one breath. Read this first; the rest expands a paragraph.
A reference for everyone shipping under the freeCodeCamp brand - the curriculum platform, the news site, the forum, every campaign page, every internal tool. Use it to make decisions; do not treat it as a style guide to memorize. When in doubt, run the call against the principles below and the do/don’ts at the bottom of the handbook.
If you are an external partner co-branding with freeCodeCamp, the Brand section is the contract you must satisfy.
Command-line Chic. Square corners. Three-pixel borders. No drop shadows. Inverted-fill primaries. Two-pixel-stroke icons on a 24-pixel grid. Lato body, Hack-ZeroSlash mono. Snap easing, 180 ms default. Direct copy. Honest empty states. Accessibility before flourish.
The whole kit is a CSS custom-property layer on top of semantic markup. Recolor the palette by swapping one class on <html>. Restyle a section by overriding tokens, never by overriding classes.
When two principles collide, this is the order of precedence:
If you find yourself bending #1 or #2 to satisfy #6, stop and escalate.
Each subsequent section is a short, opinionated read with do/don’ts. Pick the section that matches your decision - palette, type, spacing, iconography, motion, voice, brand - and follow the rules. The cross-cutting Do / Don’t gallery at the end catches composition mistakes that no single section can.
Foundations · 08
Logo usage, wordmark vs symbol, clearspace, sizing, and partner co-branding. The contract every external surface must satisfy before it carries the freeCodeCamp mark.
freeCodeCamp ships three marks. Use the right one for the surface.
freeCodeCamp rendered in bold weight at the local body font. Use as the primary logotype in product navigation, page headers, footers, and prose (“freeCodeCamp” sentence-cased, never “FreeCodeCamp” or “Freecodecamp”). The f is always lowercase, the C always uppercase.The canonical assets ship in packages/uikit-css/src/brand/ (SVG + PNG, dark + light variants, plus a printable asset kit zip). Always reach for the SVG version on the web; PNG is for embed contexts that cannot consume SVG.
Around every mark, reserve a clearspace equal to the cap height of the wordmark (or the height of the symbol’s flame element, whichever is smaller). No other element - tagline, divider, image, second logo, decorative pattern - may enter that zone. The clearspace is the mark’s silence; it is what makes the mark legible.
On dense interfaces (chips, table cells, dense toolbars), use the symbol alone at 16-24px. The wordmark needs at least 96px of width to remain readable; below that, fall back to the symbol.
Mark | Minimum width (digital) | Minimum width (print) |
|---|---|---|
| Wordmark | 96 px | 24 mm |
| Symbol | 16 px | 4 mm |
| Lockup | 144 px | 36 mm |
Below these floors the mark is decoration, not branding. Use a different mark or omit it.
The wordmark and lockup ship in two variants - light (white on dark surface) and dark (gray-90 on light surface). Use whichever provides ≥4.5:1 contrast against the background. Never recolor the mark to match a campaign palette; the brand stays steady so campaign palettes can move.
The symbol may carry the gold accent (--cta-background) when used as a favicon, app icon, or single-color mark on a neutral surface. It may not carry the danger, warning, or info palettes - those are reserved for state, not identity.
On photographic backgrounds, place the mark on a solid surface-two block (var(--background-secondary)) before composing. Direct overlay on photography is forbidden - contrast is unreliable and the mark loses its silence.
When freeCodeCamp appears alongside a partner mark (course sponsors, certification employers, university programs), follow this composition:
var(--border-strong)) separates the two, with the divider’s height equal to the taller mark’s cap height.Partner co-branding requires written approval from the freeCodeCamp brand team for any public-facing surface. Internal pilot pages are exempt; once a co-brand goes public, it goes through review.
When in doubt, the rule is conservative: the brand exists so a learner can recognize freeCodeCamp on any surface, in any country, in any context. Anything that confuses that recognition is wrong, however nice it looks.
Three official marks. The dotted frame is the clear-space envelope - nothing else enters that margin.
Primary
Full lockup - wordmark + puck. Default for hero + header.
Download SVGSecondary
Compact lockup - wordmark only. Navs, cards, footers.
Download SVGPuck
Standalone puck. Favicons, avatars, tight chrome.
Download SVGThe puck height x is the minimum margin on every side. Drag the slider to tighten the margin - the frame turns red when it falls below 0.5x.
Three failure modes. Don't stretch, don't recolour, don't sit on a busy background.
Do
Keep aspect ratio, one-tone fill, quiet surface.
Don't
Don't stretch - aspect ratio is locked.
Don't recolour - use the kit palette.
Don't sit on a busy background - find a calm surface.
One canonical spelling, one compact abbreviation, one graphic-only fallback.
freeCodeCamp
Full name. Long-form copy, first use in body text, legal, about pages. Lowercase f, capital C and C.
fCC
Short form. Breadcrumbs, tight UI chrome, code comments. Only after a full-name first use on the same surface.
Puck-only. Favicons, avatars, 32 px or smaller. Never as the sole brand cue in a header.
The three marks as a single zip. Brand PDF + Figma community file arrive post-1.0.
Download asset kitFoundations · 09
Cross-cutting visual rules that no single foundation captures. Composition, contrast, density, voice - caught here so they cannot leak into shipping pages.
padding: 13px because “it looks better” - open the inspector and find the token that solves it.<Fieldset> with a real legend. Don’t rely on visual proximity alone; assistive tech needs the structural hint.<Button variant='cta'> in the same fold; the second cancels the first.--foreground-tertiary on --background-secondary is a known borderline pair on the dark palette.<Button> and friends ship explicit :focus-visible rules.… needs a hover, click, or aria-expanded to recover the full string.Save changes). Don’t use Title Case (Save Changes) outside of marketing splash heroes.aria-label. Don’t assume meaning from the glyph alone - the meaning lives in the label.currentColor so they inherit text tone. Don’t hardcode a hex on an SVG; it will not re-theme.prefers-reduced-motion. Don’t ship a parallax hero, ever - the brand has none.packages/uikit-css/src/brand/. Don’t screenshot the wordmark off a webpage and re-embed.If you find yourself bending one of these rules, write it down in the pull request description. Brand and design review can sign off on the exception, but the friction is intentional - the rules exist because the kit’s recognizability depends on them. Two surfaces breaking the same rule is no longer an exception; it is a rule change. Open an issue against this handbook so the rule moves with the practice.
Install
UIKit is a copy-source registry, shadcn-style: components are copied into your project and tailored there - nothing installs from npm. Every page under/components/<slug>.md carries install steps, props, and the full source.
1 · Install the theme (once)
Copy tokens.css + base.css from/registry/theme.md into src/ui/theme/, then import them - tokens first. Fonts are listed in thestarter guide.
@import './ui/theme/tokens.css';
@import './ui/theme/base.css';2 · Copy a component
Grab the files from the component page (or the raw URLs it lists) and import its CSS once, globally.
# design.freecodecamp.org/components/button.md
src/ui/button/Button.tsx
src/ui/button/button.css3 · Use it - it is your code now
import { Button } from './ui/button/Button';
<Button variant="cta">Start curriculum</Button>Tailor the component freely. Recolour by editing token values in your copied tokens.css - keep the token names, and never hard-code colors in component CSS.
For coding agents
Point agents at /llms.txt (index), /registry/index.json (machine-readable manifest), or/registry/starter.md (bootstrap guide with a ready-made AGENTS.md snippet).
Bundle
design.freecodecamp.org is not a CDN - do not hotlink assets from it. Download the bundle once and serve it from your own host. One stylesheet carries every token, every component class, and every @font-face. Font URLs are relative (./fonts/*), so keep thefonts/ folder beside the stylesheet.
1. Download once (styles + fonts)
curl -O https://design.freecodecamp.org/cdn/styles.min.css
# fonts referenced by the bundle (relative ./fonts/*):
mkdir -p fonts && (cd fonts \
&& curl -O https://design.freecodecamp.org/cdn/fonts/Lato-Regular.woff \
&& curl -O https://design.freecodecamp.org/cdn/fonts/Hack-ZeroSlash-Regular.woff2)
# full file list + SRI hashes: https://design.freecodecamp.org/cdn/manifest.json2. Reference your own copy
<link rel="stylesheet" href="/styles.min.css" />The design.freecodecamp.org bundle is rolling, not version-pinned - it always reflects the current main. Your downloaded copy is your pin; re-download to update.
Tailwind
The Tailwind preset is a thin layer that translates every UIKit token into a Tailwind utility prefix. bg-cta-background reads the same CSS custom property the BEM classes do, so palette switches stay synchronized across both surfaces.
1 · Install
Install Tailwind, then copy preset.ts +plugin.ts from/registry/tailwind.md into src/ui/tailwind/.
pnpm add tailwindcss2 · Wire it up
// tailwind.config.js
import fcc from './src/ui/tailwind/preset';
export default {
presets: [fcc],
content: ['./src/**/*.{ts,tsx,astro,html}'],
theme: { extend: {} }
};/* src/index.css */
@import 'tailwindcss';
@import './ui/theme/tokens.css';3 · Pick a pattern
Don't double-import utility surfaces. Pick one of these two patterns:
tailwindcss + the copied tokens only; let the preset surface the tokens. Skip the component CSS entirely.tailwindcss so utility classes win the cascade.The shipped docs site uses (2): BEM ships as the canonical surface in the playground, and the preset is the way to drop into Tailwind for project-specific work.
Palette switching
The preset reads CSS custom properties at runtime, so swapping<html class="dark-palette"> forlight-palette instantly recolors everybg-*, text-*, andborder-* utility. No build step.
Recipes
The playground shows each component in isolation. These recipes collect the patterns that compose two or more.
Form composition
import { FormGroup } from './ui/form-group/FormGroup';
import { Input } from './ui/input/Input';
import { HelpBlock } from './ui/help-block/HelpBlock';
import { Button } from './ui/button/Button';
export function NewsletterForm() {
return (
<form onSubmit={onSubmit}>
<FormGroup>
<label htmlFor='email'>Email address</label>
<Input id='email' type='email' required />
<HelpBlock>One curriculum update per week.</HelpBlock>
</FormGroup>
<Button type='submit' variant='cta'>Subscribe</Button>
</form>
);
}Modal from a button
import { useState } from 'react';
import { Modal } from './ui/modal/Modal';
import { Button } from './ui/button/Button';
export function ResetCta() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant='danger' onClick={() => setOpen(true)}>
Reset progress
</Button>
<Modal open={open} onClose={() => setOpen(false)} title='Reset progress?'>
<Modal.Body>You will lose 17 of 32 steps.</Modal.Body>
<Modal.Footer>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant='danger' onClick={confirmReset}>Confirm</Button>
</Modal.Footer>
</Modal>
</>
);
}Toast on action
import { useToast } from './ui/toast/Toast';
import { Button } from './ui/button/Button';
export function SaveButton() {
const toast = useToast();
return (
<Button
onClick={() => {
save();
toast.create({
title: 'Saved',
description: 'Your progress is synced.',
variant: 'success'
});
}}
>
Save
</Button>
);
}Sidebar layout with navbar
import { SidebarLayout } from './ui/sidebar-layout/SidebarLayout';
import { Navbar } from './ui/navbar/Navbar';
import { Sidebar } from './ui/sidebar/Sidebar';
export function Shell({ children }) {
return (
<SidebarLayout
header={<Navbar start={<Brand />} end={<UserMenu />} />}
aside={<Sidebar sections={navSections} />}
>
{children}
</SidebarLayout>
);
}Contributing
UIKit lives in the freeCodeCamp/UIkit monorepo. Small fixes go straight to PR; larger features start with a short RFC in a GitHub Discussion.
main.type(scope): subject - title-only, no body. Use feat, fix, refactor, chore, docs, or test.pnpm install
pnpm test
pnpm build
pnpm format:check
pnpm --filter @freecodecamp/uikit-docs test:visualmain. CodeRabbit reviews; CI is the source of truth. Merges to main deploy straight todesign.freecodecamp.org - no separate release step.