Styling the Site

Chapter 4 — Styling: Dark Theme | Next.js Course
Next.js Rebuild Course Chapter 4 of 10

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 .card class 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-900 sets 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
Coming from Java/Spring: think of Tailwind like a type-safe style system. Instead of arbitrary CSS values, you pick from a defined scale — 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.

Page BG
#0d1117
site-bg
Card BG
#161b22
site-card
Deep BG
#0a0d14
site-deep
Border
#30363d
site-border
Text
#e6edf3
site-text
Muted
#8b949e
site-muted
Accent
#4db8c8
site-accent
Gold
#c9a84c
site-gold
Pink
#d4649a
site-pink
Green
#7ee787
site-green

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.

tailwind.config.ts TypeScript
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.

app/globals.css CSS
@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;
  }
}
The @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

p-4 / py-4 / px-4padding 16px / y-axis / x-axis
p-6padding 24px
p-8padding 32px
gap-4 / gap-6flexbox/grid gap
mt-2 / mb-4margin top/bottom
space-y-4gap between stack children

Typography

text-sm / text-xs14px / 12px
text-lg / text-xl18px / 20px
text-3xl / text-4xl30px / 36px
font-bold / font-medium700 / 500 weight
tracking-wide / -widestletter spacing
leading-relaxedline-height: 1.625

Layout / Flexbox

flex items-centerrow, vertically centred
flex-colcolumn direction
justify-betweenspace-between
flex-1 / flex-shrink-0grow / no-shrink
min-w-0allow flex child to shrink below content width
flex-wrapwrap onto next line

Grid

grid grid-cols-22-column grid
grid-cols-33-column grid
md:grid-cols-22 cols on md+ screens
col-span-2span 2 columns
auto-rows-frequal height rows
place-items-centercenter in both axes

Borders & Backgrounds

border1px solid
border-site-borderour custom #30363d
rounded-md / rounded-lg6px / 8px radius
rounded-fullpill shape
bg-site-accent/10accent colour at 10% opacity
divide-y divide-site-borderborder between children

Interactivity & Transitions

hover:text-whitecolour on hover
hover:border-site-accentborder highlight on hover
transition-colorssmooth colour transition
transition-allsmooth all properties
cursor-pointerpointer cursor
focus:outline-none focus:ring-2keyboard focus ring

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.

Responsive grid — the most common pattern on this site TypeScript · JSX
{/* 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 up
  • md: — 768px and up (tablet)
  • lg: — 1024px and up (desktop)
  • xl: — 1280px and up
  • 2xl: — 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-full
  • px-4 md:px-6
  • Defined as max-w-site: 900px in 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:

components/SubjectCard.tsx TypeScript · Server Component
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>
  )
}
The 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.
Live preview — what SubjectCard renders
Japanese
5 topics
Hiragana, katakana, kanji, vocabulary and grammar
42 pages
French
3 topics
Verbs, conversation, avoiding the Brussels pizza incident
18 pages
Left card = default state  ·  Right card = hovered state (cyan border + title)

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.

app/page.tsx — styled home page TypeScript · Server Component
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.

components/ContentSection.tsx TypeScript · Server Component
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:

app/globals.css — prose-site dark theme styles CSS (add to the @layer components block)
@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:

components/Callout.tsx TypeScript · Server 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:

components/Nav.tsx — updated with site tokens TypeScript · key className changes only
// 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:

Terminal bash
$ npm install clsx tailwind-merge
lib/utils.ts TypeScript
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
Using cn() in a component — active nav link TypeScript
'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.

Up next — Chapter 5: the placeholder data arrays get replaced by a real Prisma ORM connection to your MySQL database. By the end of Chapter 5, the home page will show the actual subjects from osztromok.com pulled live from the DB.