Styling the Site
Styling — Dark Theme
Configure Tailwind CSS with a custom design token system that matches the exact colour palette of osztromok.com. Build reusable styled components — subject cards, content sections, badges — and establish the responsive layout grid used throughout the site.
Chapter milestone: The site has its full dark theme — the same deep backgrounds, cyan accents, and card styles as the current osztromok.com. The home page shows real subject cards in a responsive grid. Every component follows a consistent design token system.
How Tailwind CSS works
Tailwind is a utility-first CSS framework — instead of writing custom CSS, you compose styles by adding pre-defined utility classes directly in your JSX. There's no separate stylesheet to maintain. Tailwind scans your source files, finds every class you actually use, and generates a minimal CSS file at build time.
Traditional CSS approach
- Write a
.cardclass in a stylesheet - Apply
className="card"in JSX - Jump between files to see what a component looks like
- Class names pile up and conflict over time
- Dead CSS accumulates — hard to know what's safe to delete
Tailwind approach
- Style is co-located with the component — no separate file
- Every class has one job —
bg-gray-900sets background - No naming things — no
.card-inner-wrapper-v2 - Only the classes you use are in the final CSS bundle
- Constraints: can only use values from the design scale by default
p-4 (16px), p-6 (24px), p-8 (32px).
Everything stays consistent without a style guide document.
osztromok.com colour palette
The current site uses a specific set of colours. We'll register these as
custom Tailwind tokens so you can write bg-site-card
instead of bg-[#161b22] everywhere. Change a token once — every
component that uses it updates automatically.
tailwind.config.ts — registering the design tokens
Replace the generated tailwind.config.ts with this version.
The extend block adds our custom tokens alongside Tailwind's
built-in palette — we're not replacing the default grays, just adding site-specific tokens.
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
site: {
bg: '#0d1117', // page background
card: '#161b22', // card / panel surface
deep: '#0a0d14', // deeper inset areas (code blocks)
border: '#30363d', // all borders
text: '#e6edf3', // primary text
muted: '#8b949e', // secondary / helper text
dim: '#6e7681', // very subtle / placeholder text
accent: '#4db8c8', // cyan — primary interactive colour
gold: '#c9a84c', // warnings, Japanese lesson highlights
pink: '#d4649a', // delete actions, error states
green: '#7ee787', // success, milestones
purple: '#bc8cff', // info notes
orange: '#f0883e', // caution / troubleshooting
},
},
fontFamily: {
mono: ['Consolas', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
borderRadius: {
'site': '8px', // standard card radius throughout the site
},
maxWidth: {
'site': '900px', // main content column width
},
typography: {
// Chapter 6: dark prose styles for HTML content from the DB
},
},
},
plugins: [],
}
export default config
After this, you write bg-site-bg, text-site-accent,
border-site-border etc. VS Code's Tailwind IntelliSense extension
will autocomplete all of these.
app/globals.css — base resets and CSS variables
Tailwind handles most styling, but a small globals file is useful for base resets, CSS custom properties (for use in non-Tailwind contexts like inline styles), and any styles that don't map cleanly to utility classes.
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── CSS custom properties ──────────────────────────────────── */
/* Mirrors tailwind.config.ts — lets you use var(--site-accent) */
/* in places where Tailwind classes can't reach (e.g. SVG fills) */
:root {
--site-bg: #0d1117;
--site-card: #161b22;
--site-deep: #0a0d14;
--site-border: #30363d;
--site-text: #e6edf3;
--site-muted: #8b949e;
--site-dim: #6e7681;
--site-accent: #4db8c8;
--site-gold: #c9a84c;
--site-pink: #d4649a;
--site-green: #7ee787;
--site-purple: #bc8cff;
--site-orange: #f0883e;
}
/* ── Base resets ─────────────────────────────────────────────── */
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
/* Prevent layout shift when scrollbar appears */
scrollbar-gutter: stable;
}
body {
background-color: var(--site-bg);
color: var(--site-text);
/* Subtle scrollbar styling for Webkit */
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--site-bg); }
::-webkit-scrollbar-thumb {
background: var(--site-border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover { background: var(--site-muted); }
/* ── @layer components — Tailwind component classes ─────────── */
/* These are reusable class combos extracted from repeated patterns */
@layer components {
/* The standard card surface used throughout the site */
.card {
@apply bg-site-card border border-site-border rounded-site;
}
/* Section label (small uppercase heading with line) */
.section-label {
@apply text-xs font-bold uppercase tracking-widest text-site-accent;
}
/* Pill badge (e.g. "New", "Chapter 1") */
.badge {
@apply inline-block text-xs font-bold uppercase tracking-widest
rounded-full px-3 py-0.5;
}
.badge-accent {
@apply badge bg-site-accent/10 border border-site-accent/25 text-site-accent;
}
.badge-gold {
@apply badge bg-site-gold/10 border border-site-gold/25 text-site-gold;
}
}
@layer components trick.
When you find yourself typing the same 4-5 Tailwind classes together repeatedly,
extract them into a component class with @apply. It keeps the utility
benefits (only generated if used) while reducing repetition.
.card, .badge-accent — these are good candidates.
Don't over-extract: if a pattern only appears once or twice, just repeat the utilities.
Tailwind classes used most in this project
Spacing
Typography
Layout / Flexbox
Grid
Borders & Backgrounds
Interactivity & Transitions
Breakpoints — mobile-first with Tailwind
Tailwind uses a mobile-first approach. An unprefixed class applies at
all screen sizes. Breakpoint prefixes (sm:, md:, lg:)
apply the class at that width and above.
{/* Single column on mobile, 2 columns on md (768px+), 3 on lg (1024px+) */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{subjects.map((s) => <SubjectCard key={s.id} {...s} />)}
</div>
{/* Hide on mobile, show on md+ */}
<ul className="hidden md:flex items-center gap-2">…</ul>
{/* Show only on mobile */}
<button className="md:hidden">☰</button>
{/* Different padding at different sizes */}
<main className="px-4 md:px-6 lg:px-8 py-6 md:py-10">…</main>
Breakpoint reference
- default — 0px and up (mobile)
sm:— 640px and upmd:— 768px and up (tablet)lg:— 1024px and up (desktop)xl:— 1280px and up2xl:— 1536px and up
Container pattern for this site
Rather than Tailwind's built-in container, we use a consistent max-width wrapper. Add this to every page's root element:
max-w-site mx-auto w-fullpx-4 md:px-6- Defined as
max-w-site: 900pxin tailwind.config - Root layout already applies this to
<main>
SubjectCard component
The home page displays all subjects as clickable cards. Here's the full styled component using our custom tokens:
import Link from 'next/link'
interface SubjectCardProps {
name: string
slug: string
description: string | null
subtopicCount: number
pageCount: number
}
export default function SubjectCard({ name, slug, description, subtopicCount, pageCount }: SubjectCardProps) {
return (
<Link
href={`/${slug}`}
className="group block bg-site-card border border-site-border rounded-site
p-5 hover:border-site-accent transition-colors"
>
{/* Header row */}
<div className="flex items-start justify-between gap-3 mb-3">
<h2 className="text-base font-bold text-site-text
group-hover:text-site-accent transition-colors">
{name}
</h2>
<span className="badge-accent shrink-0">
{subtopicCount} topics
</span>
</div>
{/* Description */}
{description && (
<p className="text-sm text-site-muted leading-relaxed mb-4">
{description}
</p>
)}
{/* Footer meta */}
<p className="text-xs text-site-dim">
{pageCount} pages
</p>
</Link>
)
}
group / group-hover: pattern.
Adding group to a parent element lets you style its children based on
the parent's hover state. Here, hovering the whole card (group) causes
the title to turn cyan (group-hover:text-site-accent) — no JavaScript needed.
This works for group-focus, group-active, etc. too.
The home page with the subject grid
Update app/page.tsx with a real styled layout using the subject cards.
Data is still from the placeholder array — Prisma replaces it in Chapter 5.
import SubjectCard from '@/components/SubjectCard'
// Placeholder until Prisma is set up in Chapter 5
const SUBJECTS = [
{ id: 1, name: 'Japanese', slug: 'japan', description: 'Hiragana, katakana, kanji, vocabulary and grammar', subtopicCount: 5, pageCount: 42 },
{ id: 2, name: 'Hungarian', slug: 'hungarian', description: 'Alphabet, verbs, conversation — for visiting family', subtopicCount: 3, pageCount: 14 },
{ id: 3, name: 'French', slug: 'french', description: 'Conversation, verbs, and recovering from the Brussels pizza incident', subtopicCount: 3, pageCount: 18 },
{ id: 4, name: 'Python', slug: 'python', description: 'FastAPI, web dev, and the meal planner project', subtopicCount: 4, pageCount: 22 },
{ id: 5, name: 'Bash', slug: 'bash', description: 'Scripting, automation, and server management', subtopicCount: 3, pageCount: 36 },
]
export default function HomePage() {
return (
<>
{/* Hero */}
<section className="py-12 border-b border-site-border mb-10">
<p className="section-label mb-3">Learning site</p>
<h1 className="text-4xl font-bold text-site-text mb-3">
Welcome to{' '}
<span className="text-site-accent">osztromok.com</span>
</h1>
<p className="text-site-muted max-w-lg leading-relaxed">
Philip's personal learning site — languages, programming, and more.
Pick a subject to get started.
</p>
</section>
{/* Subject grid */}
<section>
<p className="section-label mb-5">Subjects</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{SUBJECTS.map((subject) => (
<SubjectCard key={subject.id} {...subject} />
))}
</div>
</section>
</>
)
}
ContentSection component
Each page in the DB has multiple content sections — a heading and an HTML body. This component renders one section. We'll use it in Chapter 6 when pages load real data from the database.
interface ContentSectionProps {
heading: string
body: string // HTML string from the database
}
export default function ContentSection({ heading, body }: ContentSectionProps) {
return (
<section className="mb-10">
<h2 className="text-xl font-bold text-site-text mb-4 pb-3 border-b border-site-border">
{heading}
</h2>
{/* dangerouslySetInnerHTML renders the HTML string from TinyMCE */}
<div
className="prose-site"
dangerouslySetInnerHTML={{ __html: body }}
/>
</section>
)
}
dangerouslySetInnerHTML is safe here because the HTML
comes from your own admin interface — content you wrote yourself in TinyMCE.
It would be dangerous if you were rendering HTML submitted by anonymous users.
The name is intentionally scary to make developers think before using it.
The prose-site class needs to style all the HTML elements that TinyMCE might produce — headings, paragraphs, lists, code blocks, tables. Add this to globals.css:
@layer components {
/* ... existing component classes ... */
/* Dark prose — styles HTML content coming from the database */
.prose-site {
@apply text-site-muted leading-relaxed;
}
.prose-site h1, .prose-site h2, .prose-site h3,
.prose-site h4, .prose-site h5, .prose-site h6 {
@apply text-site-text font-bold mt-6 mb-3;
}
.prose-site h2 { @apply text-xl; }
.prose-site h3 { @apply text-lg; }
.prose-site p { @apply mb-4; }
.prose-site a { @apply text-site-accent hover:underline; }
.prose-site ul, .prose-site ol {
@apply mb-4 pl-6;
}
.prose-site ul { @apply list-disc; }
.prose-site ol { @apply list-decimal; }
.prose-site li { @apply mb-1; }
.prose-site code {
@apply font-mono text-sm bg-site-deep text-site-accent
px-1.5 py-0.5 rounded;
}
.prose-site pre {
@apply bg-site-deep border border-site-border rounded-site
p-4 overflow-x-auto mb-4;
}
.prose-site pre code {
@apply bg-transparent p-0 text-site-text;
}
.prose-site table {
@apply w-full border-collapse mb-4 text-sm;
}
.prose-site th {
@apply bg-site-deep text-site-dim text-xs uppercase tracking-wider
px-4 py-2 text-left border-b border-site-border;
}
.prose-site td {
@apply px-4 py-2.5 border-b border-site-border text-site-muted;
}
.prose-site blockquote {
@apply border-l-4 border-site-accent pl-4 italic text-site-muted my-4;
}
.prose-site hr {
@apply border-site-border my-8;
}
}
Tip / callout component
The current site uses coloured callout boxes — the same ones used in these lesson files.
Here's a reusable Callout component:
const VARIANTS = {
accent: 'bg-site-accent/5 border-site-accent text-site-accent/90',
gold: 'bg-site-gold/5 border-site-gold text-site-gold/90',
green: 'bg-site-green/5 border-site-green text-site-green/90',
pink: 'bg-site-pink/5 border-site-pink text-site-pink/90',
purple: 'bg-site-purple/5 border-site-purple text-site-purple/90',
orange: 'bg-site-orange/5 border-site-orange text-site-orange/90',
} as const
interface CalloutProps {
variant?: keyof typeof VARIANTS
children: React.ReactNode
}
export default function Callout({ variant = 'accent', children }: CalloutProps) {
return (
<div
className={`rounded-site px-4 py-3 border-l-4 text-sm leading-relaxed my-4 ${VARIANTS[variant]}`}
>
{children}
</div>
)
}
// Usage:
// <Callout variant="gold">
// <strong>Note:</strong> This is a highlighted tip.
// </Callout>
as const and keyof typeof are TypeScript patterns
worth knowing. as const makes the object's values literal types
(not just string). keyof typeof VARIANTS produces the union type
'accent' | 'gold' | 'green' | 'pink' | 'purple' | 'orange' automatically —
so if you add a new variant to the object, the type updates itself.
Update Nav.tsx with the full dark theme
Now that we have the design tokens, update the Nav from Chapter 3 to use the custom colour tokens instead of Tailwind's default grays:
// Replace the className values from Chapter 3 with these:
<nav className="bg-site-bg border-b border-site-border sticky top-0 z-50">
<div className="max-w-site mx-auto px-6 h-14 flex items-center justify-between">
<Link
href="/"
className="font-bold text-site-text tracking-tight hover:text-site-accent transition-colors"
>
osztromok<span className="text-site-accent">.com</span>
</Link>
<ul className="hidden md:flex items-center gap-1">
{subjects.map((s) => (
<li key={s.id}>
<Link
href={`/${s.slug}`}
className="px-3 py-1.5 text-sm text-site-muted
hover:text-site-text hover:bg-site-card
rounded-md transition-colors"
>
{s.name}
</Link>
</li>
))}
</ul>
</div>
</nav>
The cn() helper — conditional class names
When you need to conditionally combine class names (e.g. an active nav link gets
a different colour), string concatenation gets messy fast. Install
clsx and create a tiny cn() helper:
$ npm install clsx tailwind-merge
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
// cn() merges Tailwind classes intelligently:
// - clsx handles conditionals and arrays
// - twMerge resolves conflicts (e.g. "p-4 p-6" → "p-6", last wins)
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Usage examples:
// cn('p-4 text-white', isActive && 'text-site-accent') ← conditional
// cn('p-4', 'p-6') ← → 'p-6' (twMerge resolves conflict)
// cn(['flex', 'items-center'], 'gap-4') ← arrays work too
'use client'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import Link from 'next/link'
export function NavLink({ href, label }: { href: string; label: string }) {
const pathname = usePathname()
const isActive = pathname === href || pathname.startsWith(href + '/')
return (
<Link
href={href}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors',
isActive
? 'text-site-text bg-site-card' // active
: 'text-site-muted hover:text-site-text' // default
)}
>
{label}
</Link>
)
}
usePathname() needs 'use client' because it
reads the current URL from the browser. That's why NavLink must be a
Client Component — even though it's tiny and mostly static. This is a great example
of pushing the client boundary down: only the active-state logic is a Client Component;
the outer Nav that fetches subjects stays Server.
What the site looks like now
Home page shows a dark card grid
http://localhost:3000/ — five subject cards in a responsive grid.
Cards have the #161b22 background, #30363d border,
and glow cyan on hover. Matches the current osztromok.com aesthetic.
Tailwind custom tokens work
Run npm run build — no errors. The output CSS should be a few kilobytes,
not megabytes, because Tailwind only includes classes you actually used.
Responsive layout works
Resize the browser window — the grid collapses from 3 columns (desktop) to
2 (tablet) to 1 (mobile). Nav bar switches from links to hamburger menu below
the md breakpoint.
Dark scrollbar, smooth scroll
On WebKit browsers (Chrome/Edge) the scrollbar matches the dark theme. The page scrolls smoothly to anchor links.
cn() utility available
lib/utils.ts exports cn(). The NavLink
component uses usePathname() to highlight the active subject
in the navigation bar.