What You'll Build
You will build a polished, responsive player card component for a cricket statistics app — the kind of card that appears in a grid of players, showing a name, role, a key stat, and an action button. This single component exercises every foundation from Module 1: the utility-first mindset, on-scale spacing, the type and color systems, and borders/shadows for elevation. By the end you will have a reusable card that looks designed rather than assembled, flexes from phone to desktop, and uses a clean focus ring on its interactive elements. Crucially, you will build it twice — first as raw repeated utilities, then extracted into a reusable component — so you feel exactly when and why extraction earns its place, which is the core judgement utility-first demands.
Prerequisites
- A working Tailwind project (CLI, Vite, or any framework starter) with the content array configured to scan your component files, as set up in Lesson 02.
- Comfort with the spacing scale — padding, margin, and gap — and the constrain-not-fix sizing principle from Lesson 03.
- Familiarity with the type scale, hue-shade color system, and slash-opacity from Lesson 04, including pairing foreground and background for contrast.
- Understanding of the shadow elevation scale and ring-based focus indicators from Lesson 05, including why rings avoid layout shift.
- A code editor with a browser preview and Node.js installed if you are using a build tool rather than the standalone CLI.
Setup & Project Structure
Create a minimal Vite project so hot-reload shows your card update as you type. The structure stays deliberately small: a single entry HTML, a CSS file containing the three Tailwind directives, and a components folder where the card will live. Make sure the content glob in your config covers that components folder, or your classes will silently fail to generate — the exact scanner-contract issue from Lesson 02. The dependencies are just Tailwind and its peer build tooling; nothing else is required for the foundations.
# Create and enter the project
npm create vite@latest cricket-cards -- --template vanilla
cd cricket-cards
npm install
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
# Project structure
# cricket-cards/
# ├─ index.html
# ├─ tailwind.config.js <- ensure content covers ./*.html and ./src/**/*
# ├─ src/
# │ ├─ main.js
# │ ├─ style.css <- holds @tailwind directives
# │ └─ components/
# │ └─ playerCard.js <- the reusable card lives here
npm run devStep 1 — Foundation
Begin by hand-writing the card as raw utilities directly in index.html, with no abstraction at all. The goal of this step is to compose the box model, type, color, and elevation foundations into one coherent card and see them work together. You will deliberately repeat the markup for two players so the repetition becomes visible — this sets up the extraction decision in Step 3. Focus on staying on-scale: padding from the spacing scale, colors from the same hue ramp, a single shadow level and radius.
<!-- src/style.css -->
<!-- @tailwind base; @tailwind components; @tailwind utilities; -->
<!-- index.html : raw, repeated card markup (intentionally not yet abstracted) -->
<main class="min-h-screen bg-slate-100 p-6 flex flex-wrap gap-6 justify-center">
<!-- Card 1 -->
<article class="w-full max-w-xs bg-white rounded-xl shadow-md border-t-4 border-blue-700
p-5 space-y-2">
<p class="text-xs font-semibold tracking-wide text-blue-700 uppercase">Batsman</p>
<h3 class="text-xl font-bold text-slate-900">Rohit Sharma</h3>
<p class="text-sm text-slate-500">Highest ODI score: 264</p>
<button class="mt-3 w-full py-2 rounded-lg bg-blue-700 text-white font-semibold
shadow-lg hover:shadow-xl transition-shadow
focus-visible:ring-2 focus-visible:ring-blue-700 focus-visible:outline-none">
View Scorecard
</button>
</article>
<!-- Card 2 : SAME markup repeated (note the duplication) -->
<article class="w-full max-w-xs bg-white rounded-xl shadow-md border-t-4 border-blue-700
p-5 space-y-2">
<p class="text-xs font-semibold tracking-wide text-blue-700 uppercase">Bowler</p>
<h3 class="text-xl font-bold text-slate-900">Jasprit Bumrah</h3>
<p class="text-sm text-slate-500">Career economy: 4.6</p>
<button class="mt-3 w-full py-2 rounded-lg bg-blue-700 text-white font-semibold
shadow-lg hover:shadow-xl transition-shadow
focus-visible:ring-2 focus-visible:ring-blue-700 focus-visible:outline-none">
View Scorecard
</button>
</article>
</main>Step 2 — Core Logic
Now make the layout genuinely responsive and the card content robust. The wrapper already uses flex flex-wrap with gap, so cards reflow naturally; in this step you add responsive sizing so cards are full-width on phones but settle into a tidy fixed-ish width on larger screens, and you add a stat-emphasis treatment so the key number stands out. This is where the constrain-not-fix principle pays off: max-w-xs caps the card while w-full lets it shrink, so the same markup works from 320px to a wide grid without media-query juggling.
<!-- The card body, now with responsive sizing and an emphasised stat -->
<article class="w-full sm:w-72 max-w-xs bg-white rounded-xl shadow-md
border-t-4 border-blue-700 p-5 space-y-2">
<p class="text-xs font-semibold tracking-wide text-blue-700 uppercase">Batsman</p>
<h3 class="text-xl font-bold text-slate-900">Virat Kohli</h3>
<!-- Emphasised key stat: large number, muted label -->
<div class="pt-1">
<span class="text-3xl font-extrabold text-slate-900">50</span>
<span class="text-sm text-slate-500 ml-1">ODI centuries</span>
</div>
<button class="mt-3 w-full py-2 rounded-lg bg-blue-700 text-white font-semibold
shadow-lg hover:shadow-xl transition-shadow
focus-visible:ring-2 focus-visible:ring-blue-700 focus-visible:outline-none">
View Scorecard
</button>
</article>
<!-- w-full sm:w-72 : full width on phones, ~18rem from the sm breakpoint up -->Step 3 — Integration & Enhancement
The repetition from Step 1 is now a proven, stable pattern, so this is the moment extraction earns its place — the exact judgement call from Lesson 01. Extract the card into a reusable function that takes data and returns the markup, so each card is one call with its values, not a copy-pasted block. This keeps the styling in one place: change the shadow level once and every card updates. Note we extract into a component (a JS function here), not an @apply class, because the component can also hold the data shaping and, later, behaviour — styling and structure travelling together.
// src/components/playerCard.js
export function playerCard({ role, name, statValue, statLabel }) {
// Styling lives here ONCE; every card shares it.
return `
<article class="w-full sm:w-72 max-w-xs bg-white rounded-xl shadow-md
border-t-4 border-blue-700 p-5 space-y-2">
<p class="text-xs font-semibold tracking-wide text-blue-700 uppercase">${role}</p>
<h3 class="text-xl font-bold text-slate-900">${name}</h3>
<div class="pt-1">
<span class="text-3xl font-extrabold text-slate-900">${statValue}</span>
<span class="text-sm text-slate-500 ml-1">${statLabel}</span>
</div>
<button class="mt-3 w-full py-2 rounded-lg bg-blue-700 text-white font-semibold
shadow-lg hover:shadow-xl transition-shadow
focus-visible:ring-2 focus-visible:ring-blue-700 focus-visible:outline-none">
View Scorecard
</button>
</article>`;
}
// src/main.js — data-driven: each player is one object, not repeated markup
import { playerCard } from './components/playerCard.js';
const squad = [
{ role: 'Batsman', name: 'Rohit Sharma', statValue: '264', statLabel: 'highest ODI' },
{ role: 'Bowler', name: 'Jasprit Bumrah', statValue: '4.6', statLabel: 'economy' },
{ role: 'Batsman', name: 'Virat Kohli', statValue: '50', statLabel: 'ODI tons' },
];
document.querySelector('#app').innerHTML = `
<main class="min-h-screen bg-slate-100 p-6 flex flex-wrap gap-6 justify-center">
${squad.map(playerCard).join('')}
</main>`;Step 4 — Testing & Verification
Run the dev server and verify three things: the cards render with consistent elevation and spacing, they reflow from a single column on a narrow viewport to a wrapped row on a wide one, and tabbing with the keyboard shows a clear focus ring on each button with no layout shift. Resize the browser from roughly 320px upward and watch the cards transition from full-width to the capped sm:w-72 width.
# Start the dev server
npm run dev
# Expected: open the printed localhost URL. You should see:
# - Three white cards on a slate-100 background, evenly gapped and centered
# - Each with a blue top accent border, soft shadow, bold name, large stat
# - On a narrow window: cards stack full-width, one per row
# - On a wide window: cards sit ~18rem wide and wrap into a row
# - Pressing Tab: a 2px blue ring appears around the focused button,
# and NOTHING around it moves (ring is outside the box model)
# - Hovering a button: its shadow grows (lifts); content stays putWarning: The most common error here is that classes render in index.html but not in the extracted playerCard.js — the cards appear unstyled after Step 3. The cause is almost always the content array: src/components/*.js is not matched by a glob, so the scanner never reads the extracted file. Add ./src/**/*.{js,html} to content and restart the dev server; the classes will generate immediately.
Extension Challenge: Add a subtle colored glow on hover using a colored shadow with slash-opacity (hover:shadow-blue-700/30), give the card itself a hover:shadow-lg lift, and add a small rounded badge in the top-right corner using absolute positioning within a relative card — combining elevation, color opacity, and per-corner radius from across Module 1.
- Compose foundations together: a single card exercises the box model, spacing scale, type scale, color ramp, and shadow elevation as one coherent unit.
- Stay on-scale throughout — one shadow level, one radius, ramp-based colors — so the card reads as intentionally designed rather than assembled from arbitrary values.
- Use w-full with a max-width or sm: width cap so one markup adapts from phone to desktop without per-device layouts, applying the constrain-not-fix principle.
- Extract into a reusable component only after repetition proves the pattern stable, keeping styling in one place and letting the component also hold data and behaviour.
- Prefer focus-visible:ring for interactive elements so keyboard users get a clear, layout-stable focus indicator without flashing on mouse clicks.
- Remember the scanner contract — when extracted files render unstyled, the content array glob is the first and usually the only thing to check.