📅 NGÀY 8: STYLING TRONG REACT
🎯 Mục tiêu học tập (5 phút)
Sau ngày học hôm nay, bạn sẽ:
- [ ] Hiểu rõ 4 cách styling trong React và khi nào dùng cách nào
- [ ] Áp dụng được Inline Styles, CSS Classes, CSS Modules, và Tailwind CSS
- [ ] Phân biệt được trade-offs giữa các approach
- [ ] Xây dựng components với styling production-ready
🤔 Kiểm tra đầu vào (5 phút)
Trước khi bắt đầu, hãy trả lời 3 câu hỏi sau:
- Conditional Rendering: Làm thế nào để hiển thị element dựa trên condition?
- Props: Làm sao truyền data từ parent xuống child component?
- Component Composition: Tại sao nên chia nhỏ components?
Xem đáp án
- Dùng ternary
condition ? <A /> : <B />hoặc&&operator - Truyền qua attributes:
<Child name="value" /> - Để reusability, maintainability, và single responsibility
📖 PHẦN 1: GIỚI THIỆU KHÁI NIỆM (30 phút)
1.1 Vấn Đề Thực Tế
Bạn vừa xây dựng một Product Card component:
function ProductCard({ name, price, inStock }) {
return (
<div>
<h3>{name}</h3>
<p>{price}đ</p>
{inStock ? <span>Còn hàng</span> : <span>Hết hàng</span>}
</div>
);
}Vấn đề: Component này trông rất "xấu" - không màu sắc, không layout, không responsive. Làm sao để style nó?
Câu hỏi đặt ra:
- Viết CSS ở đâu? Inline hay file riêng?
- Làm sao tránh CSS conflicts giữa các components?
- Làm sao style dựa trên props (e.g.,
inStock→ màu xanh/đỏ)? - Cách nào performance tốt nhất?
1.2 Giải Pháp
React hỗ trợ 4 cách styling chính, mỗi cách có use case riêng:
| Cách tiếp cận | Trường hợp sử dụng | Ưu điểm | Nhược điểm |
|---|---|---|---|
| Inline Styles | Tạo kiểu động dựa trên state/props | Logic JS, an toàn kiểu | Không có lớp giả, dài dòng |
| CSS Classes | Kiểu toàn cục, ứng dụng đơn giản | Quen thuộc, tính năng CSS | Phạm vi toàn cục, xung đột |
| CSS Modules | Kiểu giới hạn theo component | Có phạm vi, tính năng CSS | Thêm file, bước build |
| Tailwind CSS | Phát triển UI nhanh | Ưu tiên tiện ích, không cần đặt tên | Learning curve, HTML rối |
1.3 Mô hình tư duy
┌─────────────────────────────────────────────────────┐
│ CÂY QUYẾT ĐỊNH STYLING │
├─────────────────────────────────────────────────────┤
│ │
│ Cần style động (màu sắc, kích thước từ props)? │
│ ├─ CÓ → Inline Styles │
│ └─ KHÔNG → Tiếp tục │
│ │
│ Cần tính năng CSS (hover, media queries)? │
│ ├─ KHÔNG → Inline Styles phù hợp │
│ └─ CÓ → Tiếp tục │
│ │
│ Muốn style có phạm vi riêng (tránh xung đột)? │
│ ├─ CÓ → CSS Modules hoặc Tailwind │
│ └─ KHÔNG → CSS Classes (toàn cục) │
│ │
│ Muốn phát triển nhanh với utility classes? │
│ ├─ CÓ → Tailwind CSS │
│ └─ KHÔNG → CSS Modules │
│ │
└─────────────────────────────────────────────────────┘Analogy dễ hiểu:
Styling trong React giống như trang trí một ngôi nhà:
- Inline Styles = Sơn trực tiếp lên tường (nhanh nhưng khó maintain)
- CSS Classes = Dùng giấy dán tường có sẵn (dễ nhưng dễ trùng pattern)
- CSS Modules = Custom giấy dán riêng cho từng phòng (organized, no conflicts)
- Tailwind = Lego blocks (ghép nhanh, nhưng phải học cách ghép)
1.4 Hiểu Lầm Phổ Biến
❌ Hiểu lầm 1: "Inline styles là anti-pattern trong React"
- Sự thật: Inline styles hoàn toàn OK cho dynamic values. Anti-pattern là dùng inline cho STATIC styles.
❌ Hiểu lầm 2: "CSS-in-JS (styled-components) là cách tốt nhất"
- Sự thật: Styled-components KHÔNG được dạy trong Core React vì cần thư viện external. Chúng ta học CSS Modules + Tailwind trước.
❌ Hiểu lầm 3: "Tailwind làm HTML bẩn"
- Sự thật: Trade-off giữa "HTML dài" vs "không cần nghĩ tên CSS class". Với components nhỏ, Tailwind rất hiệu quả.
❌ Hiểu lầm 4: "Nên tách hết CSS ra file riêng"
- Sự thật: Với dynamic styles (dựa vào props), inline styles tốt hơn vì giữ logic gần nhau.
💻 PHẦN 2: LIVE CODING (45 phút)
Demo 1: Inline Styles - Pattern Cơ Bản ⭐
Use case: Button component với dynamic color
❌ Cách SAI - Hard-coded values
function Button({ children }) {
return (
<button
style={{ backgroundColor: "blue", color: "white", padding: "10px" }}
>
{children}
</button>
);
}
// Vấn đề:
// 1. Không thể thay đổi màu từ props
// 2. Style object tạo mới mỗi render (performance)
// 3. Không có hover state✅ Cách ĐÚNG - Dynamic với memoization
function Button({ children, variant = "primary" }) {
// Style object ở ngoài để tái sử dụng (hoisted)
const baseStyle = {
padding: "10px 20px",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "16px",
};
// Dynamic style dựa trên variant
const variantStyles = {
primary: { backgroundColor: "#007bff", color: "white" },
secondary: { backgroundColor: "#6c757d", color: "white" },
danger: { backgroundColor: "#dc3545", color: "white" },
};
return (
<button style={{ ...baseStyle, ...variantStyles[variant] }}>
{children}
</button>
);
}
// Sử dụng:
// <Button variant="primary">Lưu</Button>
// <Button variant="danger">Xóa</Button>Tại sao tốt hơn:
- ✅ Dynamic color từ props
- ✅ Base styles được reuse
- ✅ Dễ thêm variants mới
- ⚠️ Vẫn thiếu hover state (sẽ fix ở Demo 2)
Demo 2: CSS Classes - Kịch bản Thực Tế ⭐⭐
Use case: Card component với conditional classes
Tạo file Card.css:
/* Card.css */
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
background: white;
transition: box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card--featured {
border-color: #007bff;
border-width: 2px;
}
.card__title {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
}
.card__price {
color: #28a745;
font-size: 20px;
font-weight: bold;
}
.card__badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.card__badge--in-stock {
background: #d4edda;
color: #155724;
}
.card__badge--out-of-stock {
background: #f8d7da;
color: #721c24;
}Component với CSS Classes:
import "./Card.css";
function ProductCard({ name, price, inStock, featured = false }) {
// ❌ Cách SAI - String concatenation
// const cardClass = 'card' + (featured ? ' card--featured' : '');
// ✅ Cách ĐÚNG - Template literals hoặc array
const cardClass = `card ${featured ? "card--featured" : ""}`;
// Hoặc dùng array (dễ đọc hơn với nhiều conditions):
// const cardClass = ['card', featured && 'card--featured']
// .filter(Boolean)
// .join(' ');
const badgeClass = `card__badge ${
inStock ? "card__badge--in-stock" : "card__badge--out-of-stock"
}`;
return (
<div className={cardClass}>
<h3 className="card__title">{name}</h3>
<p className="card__price">{price.toLocaleString("vi-VN")}đ</p>
<span className={badgeClass}>{inStock ? "Còn hàng" : "Hết hàng"}</span>
</div>
);
}
// Kết quả:
// <ProductCard
// name="iPhone 15 Pro"
// price={29990000}
// inStock={true}
// featured={true}
// />
// → Card có border xanh (featured), badge xanh (còn hàng), hover effect📚 Vercel Best Practice: Explicit Conditional Rendering
Khi dùng CSS classes với conditions, tránh dùng && với numbers:
// ❌ SAI - count = 0 sẽ render "0" trong className
const className = `badge ${count && "badge--active"}`;
// ✅ ĐÚNG - Explicit boolean
const className = `badge ${count > 0 ? "badge--active" : ""}`;Demo 3: CSS Modules - Edge Cases ⭐⭐⭐
Use case: Tránh global CSS conflicts
Vấn đề với CSS thường:
/* ComponentA.css */
.button {
background: blue;
}
/* ComponentB.css */
.button {
background: red; /* ❌ Conflict! Cái nào load sau sẽ override */
}Giải pháp: CSS Modules
File Button.module.css:
/* Button.module.css */
.button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.primary {
background-color: #007bff;
color: white;
}
.primary:hover {
background-color: #0056b3;
}
.secondary {
background-color: #6c757d;
color: white;
}
.danger {
background-color: #dc3545;
color: white;
}
/* Global override (escape hatch) */
:global(.force-large) {
font-size: 20px !important;
}Component:
import styles from "./Button.module.css";
function Button({ children, variant = "primary" }) {
// CSS Modules import → object với scoped class names
// styles.button → "Button_button__a1b2c"
// styles.primary → "Button_primary__d3e4f"
return (
<button className={`${styles.button} ${styles[variant]}`}>
{children}
</button>
);
}
// Hoặc dùng array method (readable hơn):
function Button({ children, variant = "primary", disabled = false }) {
const classes = [styles.button, styles[variant], disabled && styles.disabled]
.filter(Boolean)
.join(" ");
return <button className={classes}>{children}</button>;
}
// Kết quả HTML:
// <button class="Button_button__a1b2c Button_primary__d3e4f">
// Lưu
// </button>Edge Cases cần handle:
1. Dynamic class names
// ❌ SAI - Template literal không work với CSS Modules
const className = styles[`button-${variant}`]; // undefined!
// ✅ ĐÚNG - Bracket notation
const className = styles[variant]; // OK nếu variant = 'primary'
// ✅ ĐÚNG - Map object
const variantMap = {
primary: styles.primary,
secondary: styles.secondary,
danger: styles.danger,
};
const className = variantMap[variant];2. Global styles khi cần
/* Button.module.css */
/* Scoped */
.button {
padding: 10px;
}
/* Global (escape hatch) */
:global(.override-button) {
padding: 20px;
}// Mix scoped + global
<button className={`${styles.button} override-button`}>Click</button>3. Composition (importing styles từ module khác)
/* Base.module.css */
.baseButton {
padding: 10px;
border: none;
}
/* PrimaryButton.module.css */
.button {
composes: baseButton from "./Base.module.css";
background: blue;
color: white;
}Demo 4: Tailwind CSS Basics ⭐⭐⭐
Layout:
flex,grid,block,inline,hiddencontainer,mx-auto
Spacing:
p-{size},m-{size}(0, 1, 2, 4, 8, 16...)px-4,py-2,mt-8,mb-4
Colors:
bg-{color}-{shade}:bg-blue-500,bg-red-600text-{color}-{shade}:text-gray-700,text-white- Colors: blue, red, green, yellow, gray, purple, pink, indigo
Typography:
text-{size}:text-sm,text-base,text-lg,text-xlfont-{weight}:font-normal,font-semibold,font-bold
Borders:
border,border-{width},border-{color}-{shade}rounded,rounded-lg,rounded-full
Effects:
shadow,shadow-md,shadow-lghover:,focus:,active:prefixes
✅ Component với Tailwind:
function ProductCard({ name, price, inStock, featured = false }) {
return (
<div
className={`
border rounded-lg p-4 bg-white
transition-shadow duration-300
hover:shadow-lg
${featured ? "border-blue-500 border-2" : "border-gray-200"}
`}
>
<h3 className="text-lg font-semibold mb-2">{name}</h3>
<p className="text-green-600 text-xl font-bold mb-2">
{price.toLocaleString("vi-VN")}đ
</p>
<span
className={`
inline-block px-2 py-1 rounded text-xs font-semibold
${inStock ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}
`}
>
{inStock ? "Còn hàng" : "Hết hàng"}
</span>
</div>
);
}
// Kết quả: Card responsive, hover effect, conditional styling❌ Custom tailwind
// Custom theme config
<div className="bg-brand-primary"> {/* brand-primary không tồn tại */}
// Custom spacing
<div className="p-18"> {/* Chỉ có p-0, p-1, p-2, p-4, p-8, p-16... */}
// Plugins (typography, forms, etc.)
<div className="prose"> {/* @tailwindcss/typography không có */}
// JIT arbitrary values
<div className="p-[13px]"> {/* Arbitrary values không work */}
// ✅ Chỉ dùng core utilities documented
<div className="p-4 bg-blue-500 text-white rounded-lg">📚 Best Practice: Organize Tailwind Classes
// ❌ Khó đọc
<div className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow hover:shadow-lg transition-shadow duration-300">
// ✅ Dễ đọc với template literals
<div className={`
flex items-center justify-between
p-4 bg-white
border border-gray-200 rounded-lg
shadow hover:shadow-lg
transition-shadow duration-300
`}>
// ✅ Hoặc extract thành constant
const cardClasses = `
flex items-center justify-between
p-4 bg-white
border border-gray-200 rounded-lg
shadow hover:shadow-lg
transition-shadow duration-300
`;
<div className={cardClasses}>🔨 PHẦN 3: BÀI TẬP THỰC HÀNH (60 phút)
⭐ Exercise 1: Warmup - Inline Dynamic Styles (15 phút)
/**
* 🎯 Mục tiêu: Practice inline styles với dynamic values
* ⏱️ Thời gian: 15 phút
* 🚫 KHÔNG dùng: useEffect, useMemo, CSS files
*
* Requirements:
* 1. Tạo ProgressBar component nhận `percent` prop (0-100)
* 2. Bar width thay đổi theo percent
* 3. Color:
* - 0-30%: red
* - 31-70%: yellow
* - 71-100%: green
* 4. Smooth transition
*
* 💡 Gợi ý: Dùng inline styles cho width và backgroundColor
*/
// ❌ Cách SAI (Anti-pattern):
function ProgressBar({ percent }) {
// Style object recreated mỗi render
return (
<div style={{ background: "#eee", height: 20 }}>
<div
style={{
width: `${percent}%`,
height: "100%",
background: percent > 70 ? "green" : percent > 30 ? "yellow" : "red",
transition: "width 0.3s ease",
}}
/>
</div>
);
}
// ✅ Cách ĐÚNG (Best practice):
// TODO: Implement đúng với style hoisting
// 🎯 NHIỆM VỤ CỦA BẠN:
function ProgressBar({ percent }) {
// TODO:
// 1. Hoist static styles
// 2. Calculate dynamic color
// 3. Return JSX với đúng styles
}
// Test cases:
// <ProgressBar percent={25} /> → red bar, 25% width
// <ProgressBar percent={50} /> → yellow bar, 50% width
// <ProgressBar percent={90} /> → green bar, 90% width💡 Solution
/**
* ProgressBar Component
*
* @param {Object} props
* @param {number} props.percent - Progress percentage (0-100)
* @returns {JSX.Element}
*/
function ProgressBar({ percent }) {
// Hoist static styles ra ngoài để tránh recreate mỗi render
const containerStyle = {
width: "100%",
height: "20px",
backgroundColor: "#eee",
borderRadius: "4px",
overflow: "hidden",
};
// Helper function: Tính màu dựa trên percent
const getColor = (value) => {
if (value <= 30) return "#dc3545"; // red
if (value <= 70) return "#ffc107"; // yellow
return "#28a745"; // green
};
// Dynamic styles cho bar
const barStyle = {
width: `${percent}%`,
height: "100%",
backgroundColor: getColor(percent),
transition: "width 0.3s ease, background-color 0.3s ease",
};
return (
<div style={containerStyle}>
<div style={barStyle} />
</div>
);
}
// Test cases:
// <ProgressBar percent={25} /> → red bar, 25% width
// <ProgressBar percent={50} /> → yellow bar, 50% width
// <ProgressBar percent={90} /> → green bar, 90% width## 📊 Giải thích
### ✅ Điểm chính:
1. **Static styles được hoist:** `containerStyle` ở ngoài JSX
2. **Dynamic calculation:** `getColor()` function tính màu
3. **Smooth transition:** `transition` CSS property
4. **Template literals:** `${percent}%` cho width động
### ✅ Tại sao tốt:
- ✅ Base styles không recreate mỗi render
- ✅ Dynamic values (width, color) được tính đúng
- ✅ Smooth animation khi percent thay đổi
- ✅ Clean, readable code⭐⭐ Exercise 2: Pattern Recognition - CSS vs Tailwind (25 phút)
/**
* 🎯 Mục tiêu: So sánh và chọn approach phù hợp
* ⏱️ Thời gian: 25 phút
*
* Scenario: Bạn đang build Alert component với 3 variants
*
* 🤔 PHÂN TÍCH:
* Approach A: CSS Classes (global)
* Pros: Familiar, full CSS power
* Cons: Potential conflicts, need naming
*
* Approach B: CSS Modules
* Pros: Scoped, full CSS power
* Cons: Extra file, mapping syntax
*
* Approach C: Tailwind
* Pros: Fast, no naming, utility-first
* Cons: Long className, learning curve
*
* 💭 BẠN CHỌN GÌ VÀ TẠI SAO?
* Write your decision in comments, then implement
*/
// Design spec:
/**
* Alert variants:
* - success: green bg, dark green text, checkmark icon
* - error: red bg, dark red text, X icon
* - warning: yellow bg, dark yellow text, ! icon
*
* Common:
* - Rounded corners
* - Padding
* - Border left (thick, variant color)
*/
// TODO: Implement bằng approach bạn chọn
function Alert({ variant, children }) {
// Your implementation
}
// Test:
// <Alert variant="success">Saved successfully!</Alert>
// <Alert variant="error">Something went wrong</Alert>
// <Alert variant="warning">Please review</Alert>💡 Solution - Approach A: CSS Classes
/**
* Alert Component - CSS Classes Approach
*
* Decision: CSS Classes
* Rationale:
* - Simple component, unlikely to have naming conflicts
* - Team familiar with traditional CSS
* - Full control over hover states and animations
*
* @param {Object} props
* @param {('success'|'error'|'warning')} props.variant - Alert type
* @param {React.ReactNode} props.children - Alert content
* @returns {JSX.Element}
*/
// Alert.css
/*
.alert {
padding: 12px 16px;
border-radius: 4px;
border-left: 4px solid;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
line-height: 1.5;
}
.alert__icon {
flex-shrink: 0;
font-size: 18px;
font-weight: bold;
}
.alert--success {
background-color: #d4edda;
color: #155724;
border-left-color: #28a745;
}
.alert--error {
background-color: #f8d7da;
color: #721c24;
border-left-color: #dc3545;
}
.alert--warning {
background-color: #fff3cd;
color: #856404;
border-left-color: #ffc107;
}
*/
// Alert.jsx
import "./Alert.css";
function Alert({ variant, children }) {
// Icon mapping
const icons = {
success: "✓",
error: "✕",
warning: "!",
};
// Build className với template literal
const alertClass = `alert alert--${variant}`;
return (
<div className={alertClass}>
<span className="alert__icon">{icons[variant]}</span>
<div>{children}</div>
</div>
);
}
// Test cases:
// <Alert variant="success">Saved successfully!</Alert>
// <Alert variant="error">Something went wrong</Alert>
// <Alert variant="warning">Please review</Alert>💡 Solution - Approach B: CSS Modules
/**
* Alert Component - CSS Modules Approach
*
* Decision: CSS Modules
* Rationale:
* - Component library với nhiều components → tránh conflicts
* - Scoped styles, không ảnh hưởng global
* - Vẫn giữ được full CSS power
*
* @param {Object} props
* @param {('success'|'error'|'warning')} props.variant - Alert type
* @param {React.ReactNode} props.children - Alert content
* @returns {JSX.Element}
*/
// Alert.module.css
/*
.alert {
padding: 12px 16px;
border-radius: 4px;
border-left: 4px solid;
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
line-height: 1.5;
}
.icon {
flex-shrink: 0;
font-size: 18px;
font-weight: bold;
}
.success {
background-color: #d4edda;
color: #155724;
border-left-color: #28a745;
}
.error {
background-color: #f8d7da;
color: #721c24;
border-left-color: #dc3545;
}
.warning {
background-color: #fff3cd;
color: #856404;
border-left-color: #ffc107;
}
*/
// Alert.jsx
import styles from "./Alert.module.css";
function Alert({ variant, children }) {
// Icon mapping
const icons = {
success: "✓",
error: "✕",
warning: "!",
};
// Combine base + variant classes
// styles.alert → "Alert_alert__x1y2z"
// styles[variant] → "Alert_success__a3b4c"
const alertClass = `${styles.alert} ${styles[variant]}`;
return (
<div className={alertClass}>
<span className={styles.icon}>{icons[variant]}</span>
<div>{children}</div>
</div>
);
}
// Test cases:
// <Alert variant="success">Saved successfully!</Alert>
// <Alert variant="error">Something went wrong</Alert>
// <Alert variant="warning">Please review</Alert>💡 Solution - Approach C: Tailwind CSS
/**
* Alert Component - Tailwind Approach
*
* Decision: Tailwind CSS
* Rationale:
* - Rapid development, no need to name CSS classes
* - Design system constraints (spacing, colors) enforced
* - Component co-located với styles (no separate file)
*
* @param {Object} props
* @param {('success'|'error'|'warning')} props.variant - Alert type
* @param {React.ReactNode} props.children - Alert content
* @returns {JSX.Element}
*/
function Alert({ variant, children }) {
// Icon mapping
const icons = {
success: "✓",
error: "✕",
warning: "!",
};
// Variant styles mapping
const variantStyles = {
success: "bg-green-100 text-green-800 border-green-500",
error: "bg-red-100 text-red-800 border-red-500",
warning: "bg-yellow-100 text-yellow-800 border-yellow-500",
};
return (
<div
className={`
flex items-center gap-3
p-3 rounded
border-l-4
text-sm
${variantStyles[variant]}
`}
>
<span className="flex-shrink-0 text-lg font-bold">{icons[variant]}</span>
<div>{children}</div>
</div>
);
}
// Test cases:
// <Alert variant="success">Saved successfully!</Alert>
// <Alert variant="error">Something went wrong</Alert>
// <Alert variant="warning">Please review</Alert>📊 So sánh 3 Approaches
### Decision Matrix:
| Criteria | CSS Classes | CSS Modules | Tailwind |
| ------------------- | ---------------- | -------------------- | -------------- |
| **Setup time** | 5 min (CSS file) | 7 min (CSS + config) | 2 min (inline) |
| **Readability** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| **Maintainability** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| **No conflicts** | ❌ | ✅ | ✅ |
| **Team learning** | None | Minimal | Moderate |
### ✅ Khi nào dùng cái nào:
**CSS Classes:**
- ✅ Small project (<10 components)
- ✅ Team quen CSS truyền thống
- ✅ Cần full CSS control (complex animations)
**CSS Modules:**
- ✅ Medium/Large project
- ✅ Component library
- ✅ Cần scoping + full CSS power
**Tailwind:**
- ✅ Rapid prototyping
- ✅ Design system có sẵn
- ✅ Team prefer utility-first⭐⭐⭐ Exercise 3: Real Scenario - Card Grid (40 phút)
/**
* 🎯 Mục tiêu: Build production-ready component
* ⏱️ Thời gian: 40 phút
*
* 📋 Product Requirements:
* User Story: "Là user, tôi muốn xem danh sách sản phẩm dạng grid
* với hover effects và responsive layout"
*
* ✅ Acceptance Criteria:
* - [ ] Grid: 3 columns desktop, 2 tablet, 1 mobile
* - [ ] Card hover: lift up + shadow
* - [ ] Image: rounded, aspect ratio 1:1
* - [ ] Badge "Sale" nếu có discount
* - [ ] Button disabled nếu out of stock
*
* 🎨 Technical Constraints:
* - Dùng CSS Modules hoặc Tailwind (chọn 1)
* - Không dùng external libraries
* - Code phải clean, readable
*
* 🚨 Edge Cases cần handle:
* - Long product names (truncate)
* - Missing images (placeholder)
* - Price = 0 (show "Contact")
* - Discount > 50% (badge "Hot Deal")
*/
const products = [
{
id: 1,
name: "iPhone 15 Pro Max",
price: 29990000,
image: "/iphone.jpg",
inStock: true,
discount: 0,
},
{
id: 2,
name: "Samsung Galaxy S24 Ultra",
price: 27990000,
image: "/samsung.jpg",
inStock: true,
discount: 15,
},
{
id: 3,
name: 'MacBook Pro 16"',
price: 0, // Contact for price
image: "/macbook.jpg",
inStock: false,
discount: 0,
},
];
// 📝 Implementation Checklist:
// - [ ] ProductGrid component (grid layout)
// - [ ] ProductCard component (individual card)
// - [ ] Responsive (media queries hoặc Tailwind md:, lg:)
// - [ ] Hover states
// - [ ] Conditional rendering (badge, button state)
// - [ ] Edge cases handled
function ProductGrid({ products }) {
// TODO: Implement
}
function ProductCard({ product }) {
// TODO: Implement
}
// Expected output:
/**
* Desktop (3 cols):
* ┌──────┐ ┌──────┐ ┌──────┐
* │ Card │ │ Card │ │ Card │
* └──────┘ └──────┘ └──────┘
*
* Tablet (2 cols):
* ┌──────┐ ┌──────┐
* │ Card │ │ Card │
* └──────┘ └──────┘
*
* Mobile (1 col):
* ┌──────┐
* │ Card │
* └──────┘
*/💡 Solution - Approach A: CSS Modules
/**
* ProductGrid Component - CSS Modules Approach
*
* @param {Object} props
* @param {Array} props.products - Array of product objects
* @returns {JSX.Element}
*/
// ProductGrid.module.css
/*
.grid {
display: grid;
gap: 24px;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
/* Mobile: 1 column */
@media (min-width: 0) {
.grid {
grid-template-columns: 1fr;
}
}
/* Tablet: 2 columns */
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Desktop: 3 columns */
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
.card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
cursor: pointer;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.imageContainer {
position: relative;
width: 100%;
padding-top: 100%; /* 1:1 aspect ratio */
background: #f5f5f5;
overflow: hidden;
}
.image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.imagePlaceholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #e0e0e0;
color: #999;
font-size: 48px;
}
.badge {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: white;
}
.badgeSale {
background: #ff6b6b;
}
.badgeHotDeal {
background: #ff3838;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.content {
padding: 16px;
}
.name {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.priceContainer {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.price {
font-size: 20px;
font-weight: bold;
color: #28a745;
}
.priceOriginal {
font-size: 14px;
color: #999;
text-decoration: line-through;
}
.contact {
font-size: 14px;
color: #007bff;
font-weight: 600;
}
.button {
width: 100%;
padding: 10px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.3s ease;
}
.buttonPrimary {
background: #007bff;
color: white;
}
.buttonPrimary:hover {
background: #0056b3;
}
.buttonDisabled {
background: #e0e0e0;
color: #999;
cursor: not-allowed;
}
*/
import styles from './ProductGrid.module.css';
function ProductGrid({ products }) {
return (
<div className={styles.grid}>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
function ProductCard({ product }) {
const { name, price, image, inStock, discount } = product;
// Calculate discounted price
const discountedPrice = discount > 0
? price - (price * discount / 100)
: price;
// Handle image error
const handleImageError = (e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
};
return (
<div className={styles.card}>
{/* Image */}
<div className={styles.imageContainer}>
{image ? (
<>
<img
src={image}
alt={name}
className={styles.image}
onError={handleImageError}
/>
<div
className={styles.imagePlaceholder}
style={{ display: 'none' }}
>
📷
</div>
</>
) : (
<div className={styles.imagePlaceholder}>📷</div>
)}
{/* Badge */}
{discount > 0 && (
<span
className={`${styles.badge} ${
discount > 50 ? styles.badgeHotDeal : styles.badgeSale
}`}
>
{discount > 50 ? '🔥 Hot Deal' : `Sale ${discount}%`}
</span>
)}
</div>
{/* Content */}
<div className={styles.content}>
<h3 className={styles.name} title={name}>
{name}
</h3>
{/* Price */}
<div className={styles.priceContainer}>
{price === 0 ? (
<span className={styles.contact}>Liên hệ để biết giá</span>
) : (
<>
<span className={styles.price}>
{discountedPrice.toLocaleString('vi-VN')}đ
</span>
{discount > 0 && (
<span className={styles.priceOriginal}>
{price.toLocaleString('vi-VN')}đ
</span>
)}
</>
)}
</div>
{/* Button */}
<button
className={`${styles.button} ${
inStock ? styles.buttonPrimary : styles.buttonDisabled
}`}
disabled={!inStock}
>
{inStock ? 'Thêm vào giỏ' : 'Hết hàng'}
</button>
</div>
</div>
);
}
// Export
// <ProductGrid products={products} />💡 Solution - Approach B: Tailwind CSS
/**
* ProductGrid Component - Tailwind Approach
*
* @param {Object} props
* @param {Array} props.products - Array of product objects
* @returns {JSX.Element}
*/
function ProductGrid({ products }) {
return (
<div
className="
grid gap-6 p-5
grid-cols-1
md:grid-cols-2
lg:grid-cols-3
max-w-6xl mx-auto
"
>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
function ProductCard({ product }) {
const { name, price, image, inStock, discount } = product;
// Calculate discounted price
const discountedPrice =
discount > 0 ? price - (price * discount) / 100 : price;
// Handle image error
const handleImageError = (e) => {
e.target.style.display = "none";
e.target.nextSibling.style.display = "flex";
};
return (
<div
className="
bg-white border border-gray-200 rounded-lg overflow-hidden
transition-all duration-300
hover:-translate-y-1 hover:shadow-lg
cursor-pointer
"
>
{/* Image Container */}
<div className="relative w-full pt-[100%] bg-gray-100 overflow-hidden">
{image ? (
<>
<img
src={image}
alt={name}
className="absolute top-0 left-0 w-full h-full object-cover"
onError={handleImageError}
/>
<div className="absolute top-0 left-0 w-full h-full hidden items-center justify-center bg-gray-200 text-gray-400 text-5xl">
📷
</div>
</>
) : (
<div className="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-gray-200 text-gray-400 text-5xl">
📷
</div>
)}
{/* Badge */}
{discount > 0 && (
<span
className={`
absolute top-3 right-3
px-3 py-1 rounded
text-xs font-semibold text-white
${discount > 50 ? "bg-red-600 animate-pulse" : "bg-red-400"}
`}
>
{discount > 50 ? "🔥 Hot Deal" : `Sale ${discount}%`}
</span>
)}
</div>
{/* Content */}
<div className="p-4">
{/* Product Name */}
<h3
className="text-base font-semibold mb-2 text-gray-800 overflow-hidden text-ellipsis whitespace-nowrap"
title={name}
>
{name}
</h3>
{/* Price */}
<div className="flex items-center gap-2 mb-3">
{price === 0 ? (
<span className="text-sm text-blue-600 font-semibold">
Liên hệ để biết giá
</span>
) : (
<>
<span className="text-xl font-bold text-green-600">
{discountedPrice.toLocaleString("vi-VN")}đ
</span>
{discount > 0 && (
<span className="text-sm text-gray-400 line-through">
{price.toLocaleString("vi-VN")}đ
</span>
)}
</>
)}
</div>
{/* Button */}
<button
className={`
w-full py-2 px-4 rounded
text-sm font-semibold
transition-colors duration-300
${
inStock
? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
: "bg-gray-200 text-gray-400 cursor-not-allowed"
}
`}
disabled={!inStock}
>
{inStock ? "Thêm vào giỏ" : "Hết hàng"}
</button>
</div>
</div>
);
}📊 Giải thích
### ✅ Acceptance Criteria đã đáp ứng:
1. **Grid responsive:**
- CSS Modules: `@media` queries
- Tailwind: `grid-cols-1 md:grid-cols-2 lg:grid-cols-3`
2. **Card hover effects:**
- Lift up: `transform: translateY(-4px)` / `hover:-translate-y-1`
- Shadow: `box-shadow` / `hover:shadow-lg`
3. **Image aspect ratio 1:1:**
- `padding-top: 100%` trick / `pt-[100%]`
- Absolute positioned image inside
4. **Badge "Sale":**
- Conditional: `discount > 0`
- Hot Deal: `discount > 50` → pulse animation
5. **Button disabled:**
- `disabled={!inStock}`
- Different styles cho disabled state
### ✅ Edge Cases đã handle:
1. **Long product names:**
- `text-overflow: ellipsis` + `white-space: nowrap`
- `title` attribute để show full name on hover
2. **Missing images:**
- Placeholder với icon 📷
- `onError` handler để fallback
3. **Price = 0:**
- Show "Liên hệ để biết giá"
- Conditional rendering
4. **Discount > 50%:**
- Badge "🔥 Hot Deal"
- Pulse animation (CSS Modules) / `animate-pulse` (Tailwind)
### ✅ Production Quality:
- ✅ Clean component structure (Grid + Card separated)
- ✅ Responsive design (mobile-first)
- ✅ Smooth transitions
- ✅ Accessibility (alt text, title, disabled state)
- ✅ Vietnamese locale formatting⭐⭐⭐⭐ Exercise 4: Architecture Decision - Style System (60 phút)
/**
* 🎯 Mục tiêu: Đưa ra quyết định kiến trúc có lý do
* ⏱️ Thời gian: 60 phút
*
* 🏗️ PHASE 1: Research & Design (20 phút)
*
* Scenario: Bạn là tech lead của startup, cần chọn styling approach
* cho React app mới (dashboard admin với ~50 components)
*
* Nhiệm vụ:
* 1. So sánh ít nhất 3 approaches (CSS Modules, Tailwind, Styled-components)
* 2. Document pros/cons mỗi approach
* 3. Chọn approach phù hợp nhất
* 4. Viết ADR (Architecture Decision Record)
*
* ADR Template:
* - Context: Team size, timeline, skill level
* - Decision: Approach đã chọn
* - Rationale: Tại sao (ít nhất 3 reasons)
* - Consequences: Trade-offs accepted
* - Alternatives Considered: Các options khác và tại sao không chọn
*
* 💻 PHASE 2: Implementation (30 phút)
*
* Implement 1 component phức tạp với approach đã chọn:
* - Sidebar navigation (collapsible)
* - Active state
* - Hover effects
* - Responsive
* - Theme support (light/dark)
*/
// ADR Template
/**
* # ADR: Styling Approach for Admin Dashboard
*
* ## Context
* - Team: [size, skill level]
* - Timeline: [deadline]
* - Requirements: [key features]
*
* ## Decision
* We will use [APPROACH] for styling.
*
* ## Rationale
* 1. [Reason 1]
* 2. [Reason 2]
* 3. [Reason 3]
*
* ## Consequences
* ### Positive
* - [Benefit 1]
* - [Benefit 2]
*
* ### Negative
* - [Trade-off 1]
* - [Trade-off 2]
*
* ## Alternatives Considered
* ### [Approach A]
* Rejected because: [reason]
*
* ### [Approach B]
* Rejected because: [reason]
*/
// TODO: Write your ADR above
// 🧪 PHASE 3: Proof of Concept (10 phút)
// Implement Sidebar với approach đã chọn
function Sidebar({ isCollapsed, theme }) {
// TODO: Implement với approach bạn chọn
// Requirements:
// - Collapsible (width changes)
// - Menu items with icons
// - Active state highlighting
// - Hover effects
// - Light/dark theme support
}
// Expected behavior:
// isCollapsed=false → Full width (240px), show text + icons
// isCollapsed=true → Narrow (60px), show icons only
// theme="dark" → Dark background, light text
// theme="light" → Light background, dark text💡 Solution
# ADR: Styling Approach for Admin Dashboard
## Context
- **Team**: 5 developers (2 senior, 3 mid/junior), frontend-focused startup
- **Timeline**: MVP cần ra mắt trong 10–12 tuần, sau đó iterate nhanh (2 tuần/sprint)
- **Skill level**: Team đã có kinh nghiệm với Tailwind (3/5 người dùng thường xuyên), 2 người mới nhưng học nhanh, mọi người đều quen CSS Modules cơ bản
- **Requirements chính**:
- ~50–70 components (dashboard admin: sidebar, table, forms, charts, modals, cards…)
- Responsive (desktop + tablet, không cần mobile-first nghiêm ngặt)
- Hỗ trợ light/dark theme (khách hàng yêu cầu)
- Tốc độ phát triển UI là ưu tiên hàng đầu
- Muốn giảm thiểu thời gian debug CSS conflict & naming class
- Không muốn thêm runtime overhead lớn (như styled-components)
## Decision
We will use **Tailwind CSS** (utility-first) làm styling approach chính cho toàn bộ admin dashboard.
## Rationale
1. **Tốc độ phát triển UI nhanh nhất** — Không cần tạo file CSS riêng, không cần đặt tên class, mọi style nằm ngay trong JSX → phù hợp timeline gấp và startup cần iterate nhanh.
2. **Responsive & dark mode tích hợp cực kỳ tiện lợi** — `md:`, `lg:`, `dark:` prefix giúp xử lý responsive và theme chỉ trong 1 dòng class → giảm ~40–50% thời gian viết style so với CSS Modules.
3. **Không còn vấn đề global namespace & class naming hell** — Utility classes loại bỏ hoàn toàn việc tranh cãi tên class, override không mong muốn, hoặc debug specificity.
4. **Consistency cao nhờ design tokens** — Tailwind config (spacing, colors, shadows…) ép team dùng scale thống nhất → giảm "magic numbers" và design drift.
5. **Onboarding nhanh cho junior** — Chỉ cần học ~100–150 utility phổ biến là code được UI đẹp, không cần hiểu sâu CSS cascade/specificity ngay từ đầu.
## Consequences
### Positive
- Phát triển giao diện nhanh gấp 2–3 lần so với CSS Modules hoặc styled-components
- Dark mode gần như miễn phí (chỉ thêm `dark:` prefix)
- Dễ refactor khi design system thay đổi (chỉ tìm & thay class)
- Bundle size nhỏ nhờ PurgeCSS/JIT mode
- Team có thể tập trung vào logic & state thay vì CSS maintainability
### Negative (trade-offs accepted)
- ClassName dài, JSX trông "bẩn" hơn (đặc biệt với component phức tạp)
- Learning curve ban đầu cho 2 junior chưa dùng Tailwind (ước tính 1–2 tuần)
- Khó viết animation/custom transition phức tạp (cần custom CSS hoặc plugin)
- Phụ thuộc vào Tailwind config (nhưng có thể customize thoải mái)
- Không lý tưởng nếu sau này cần pixel-perfect design từ designer (nhưng dashboard admin thường không yêu cầu mức đó)
## Alternatives Considered
### CSS Modules + CSS Variables
Rejected because:
- Phải tạo file .module.css riêng cho mỗi component → chuyển tab liên tục, chậm phát triển
- Cần đặt tên class có ý nghĩa → tốn thời gian & dễ gây tranh cãi trong team
- Dark mode phải viết lại selector phức tạp (hoặc dùng nhiều class)
- Responsive cần viết media query thủ công → code dài hơn nhiều so với Tailwind breakpoints
### Styled-components / Emotion
Rejected because:
- Thêm runtime overhead (không lý tưởng cho dashboard có nhiều table/chart)
- Bundle size lớn hơn Tailwind + PurgeCSS (đặc biệt khi có nhiều component)
- Cần học cú pháp mới (styled.div`...`) → tăng cognitive load cho junior
- Theme/dark mode phức tạp hơn (cần ThemeProvider + nhiều boilerplate)
- Không phù hợp khi ưu tiên tốc độ phát triển & stack đơn giản
=> **Tailwind là lựa chọn cân bằng tốt nhất** giữa tốc độ, maintainability, consistency và khả năng mở rộng cho dự án admin dashboard này.PHASE 3: Proof of Concept – Sidebar với Tailwind
/**
* Sidebar - Thanh điều hướng collapsible, hỗ trợ light/dark theme
* @param {boolean} isCollapsed - Trạng thái thu gọn sidebar
* @param {'light' | 'dark'} [theme='light'] - Theme hiện tại
*/
function Sidebar({ isCollapsed = false, theme = "light" }) {
const isDark = theme === "dark";
const menuItems = [
{ icon: "🏠", label: "Dashboard", active: true },
{ icon: "👤", label: "Users", active: false },
{ icon: "📦", label: "Products", active: false },
{ icon: "📊", label: "Analytics", active: false },
{ icon: "⚙️", label: "Settings", active: false },
{ icon: "🚪", label: "Logout", active: false },
];
return (
<aside
className={`
fixed inset-y-0 left-0 z-30
flex flex-col
transition-all duration-300 ease-in-out
${isCollapsed ? "w-16" : "w-64"}
${
isDark
? "bg-gray-900 text-gray-100 border-r border-gray-800"
: "bg-white text-gray-800 border-r border-gray-200 shadow-sm"
}
`}
>
{/* Logo / Brand */}
<div
className={`
h-16 flex items-center justify-center border-b
${isDark ? "border-gray-800" : "border-gray-200"}
${isCollapsed ? "px-0" : "px-6"}
`}
>
{!isCollapsed ? (
<span className="text-xl font-bold tracking-tight">Admin</span>
) : (
<span className="text-2xl font-black">A</span>
)}
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-6 space-y-1 overflow-y-auto">
{menuItems.map((item, index) => (
<button
key={index}
className={`
w-full flex items-center gap-3 px-3 py-3 rounded-lg
transition-colors duration-150 text-left
${isCollapsed ? "justify-center px-2" : ""}
${
item.active
? isDark
? "bg-gray-800 text-white font-medium"
: "bg-blue-50 text-blue-700 font-medium"
: isDark
? "text-gray-400 hover:bg-gray-800 hover:text-gray-200"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
}
`}
>
<span className="text-xl w-6 text-center flex-shrink-0">
{item.icon}
</span>
{!isCollapsed && (
<span className="font-medium truncate">{item.label}</span>
)}
</button>
))}
</nav>
{/* User section */}
<div
className={`
p-4 border-t
${isDark ? "border-gray-800" : "border-gray-200"}
`}
>
<div
className={`
flex items-center gap-3
${isCollapsed ? "justify-center" : ""}
`}
>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-medium shrink-0">
T
</div>
{!isCollapsed && (
<div className="min-w-0">
<p className="font-medium truncate">Tuân</p>
<p className="text-sm opacity-70 truncate">Admin</p>
</div>
)}
</div>
</div>
</aside>
);
}Demo sử dụng (để kiểm tra):
function AppDemo() {
const [collapsed, setCollapsed] = React.useState(false);
const [theme, setTheme] = React.useState("light");
return (
<div
className={`min-h-screen ${theme === "dark" ? "bg-gray-950" : "bg-gray-50"}`}
>
<div className="flex">
<Sidebar isCollapsed={collapsed} theme={theme} />
<div
className={`flex-1 transition-all duration-300 ${collapsed ? "ml-16" : "ml-64"}`}
>
<header className="p-6 border-b bg-white dark:bg-gray-900 dark:border-gray-800">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<div className="flex gap-4">
<button
onClick={() => setCollapsed(!collapsed)}
className="px-4 py-2 bg-gray-200 dark:bg-gray-800 rounded-lg"
>
{collapsed ? "Expand" : "Collapse"}
</button>
<button
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg"
>
{theme === "light" ? "Dark" : "Light"} Mode
</button>
</div>
</div>
</header>
<main className="p-8">
<p>Nội dung chính ở đây...</p>
</main>
</div>
</div>
</div>
);
}Đặc điểm nổi bật:
- Collapsible: width thay đổi mượt (transition-all), text ẩn khi collapsed
- Active/Hover: background & text color thay đổi rõ ràng theo theme
- Dark mode: chỉ cần thêm
dark:prefix + class điều kiện - Responsive: sidebar fixed left, content tự động margin-left theo width
- Clean code: className chia dòng, logic theme rõ ràng
→ Tailwind giúp implement nhanh, code dễ đọc và mở rộng (thêm dark mode, animation, responsive breakpoints chỉ cần thêm vài class).
⭐⭐⭐⭐⭐ Exercise 5: Production Challenge - Design System (90 phút)
/**
* 🎯 Mục tiêu: Build reusable design system foundation
* ⏱️ Thời gian: 90 phút
*
* 📋 Feature Specification:
* Tạo Design System với các components cơ bản:
* 1. Button (5 variants, 3 sizes, loading state)
* 2. Input (text, email, password, error state)
* 3. Card (header, body, footer, variants)
*
* 🏗️ Technical Design Doc:
*
* 1. Component Architecture
* - Atomic design: Button, Input (atoms)
* - Card (molecule)
* - Shared theme system
*
* 2. Styling Strategy
* - CSS Modules cho component styles
* - CSS Variables cho theme
* - Inline styles cho dynamic props
*
* 3. API Design
* - Consistent prop names across components
* - Sensible defaults
* - TypeScript types (optional)
*
* 4. Performance Considerations
* - Hoist static styles
* - Avoid style recalculation
* - Minimal CSS specificity
*/
// Theme definition (CSS Variables)
/**
* :root {
* --color-primary: #007bff;
* --color-secondary: #6c757d;
* --color-success: #28a745;
* --color-danger: #dc3545;
* --color-warning: #ffc107;
*
* --spacing-xs: 4px;
* --spacing-sm: 8px;
* --spacing-md: 16px;
* --spacing-lg: 24px;
*
* --radius-sm: 4px;
* --radius-md: 8px;
* --radius-lg: 12px;
* }
*/
// ✅ Production Checklist:
// Component Quality:
// - [ ] Prop validation (PropTypes hoặc TypeScript)
// - [ ] Default props
// - [ ] Error boundaries (where applicable)
// - [ ] Loading states
// - [ ] Disabled states
// - [ ] Focus states (a11y)
// Styling Quality:
// - [ ] Consistent naming (BEM hoặc camelCase)
// - [ ] No hardcoded colors (use CSS vars)
// - [ ] Responsive (mobile-first)
// - [ ] Dark mode support
// - [ ] Smooth transitions
// Code Quality:
// - [ ] Clean, readable code
// - [ ] Comments for complex logic
// - [ ] No magic numbers
// - [ ] Reusable utilities
// 1️⃣ Button Component
/**
* API:
* <Button
* variant="primary|secondary|success|danger|warning"
* size="sm|md|lg"
* loading={boolean}
* disabled={boolean}
* onClick={function}
* >
* Text
* </Button>
*/
function Button({
children,
variant = "primary",
size = "md",
loading = false,
disabled = false,
onClick,
}) {
// TODO: Implement
// Requirements:
// - Style dựa trên variant (colors from CSS vars)
// - Size affects padding, font-size
// - Loading state: show spinner, disable clicks
// - Disabled state: reduced opacity, no pointer
// - Hover/focus states
}
// 2️⃣ Input Component
/**
* API:
* <Input
* type="text|email|password"
* label="Label text"
* error="Error message"
* disabled={boolean}
* value={string}
* onChange={function}
* />
*/
function Input({
type = "text",
label,
error,
disabled = false,
value,
onChange,
...props
}) {
// TODO: Implement
// Requirements:
// - Label above input
// - Error state (red border, error message below)
// - Disabled state
// - Focus state (blue border)
// - Proper HTML structure (label + input)
}
// 3️⃣ Card Component
/**
* API:
* <Card variant="default|outlined|elevated">
* <Card.Header>Title</Card.Header>
* <Card.Body>Content</Card.Body>
* <Card.Footer>Actions</Card.Footer>
* </Card>
*/
function Card({ children, variant = "default" }) {
// TODO: Implement
// Compound component pattern
}
Card.Header = function CardHeader({ children }) {
// TODO: Implement
};
Card.Body = function CardBody({ children }) {
// TODO: Implement
};
Card.Footer = function CardFooter({ children }) {
// TODO: Implement
};
// 📝 Documentation Requirements:
/**
* Tạo README.md với:
* 1. Component overview
* 2. Installation & setup
* 3. Usage examples (code snippets)
* 4. Props API table
* 5. Customization guide (CSS vars)
* 6. Accessibility notes
*/
// 🧪 Demo Page
function DesignSystemDemo() {
return (
<div style={{ padding: "40px", maxWidth: "800px", margin: "0 auto" }}>
<h1>Design System Demo</h1>
{/* Button Demo */}
<section>
<h2>Buttons</h2>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="success">Success</Button>
<Button variant="danger">Danger</Button>
<Button variant="warning">Warning</Button>
</div>
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
<Button loading>Loading</Button>
<Button disabled>Disabled</Button>
</div>
</section>
{/* Input Demo */}
<section style={{ marginTop: "40px" }}>
<h2>Inputs</h2>
<div style={{ maxWidth: "400px" }}>
<Input label="Name" />
<Input label="Email" type="email" />
<Input label="Password" type="password" />
<Input label="Error Example" error="This field is required" />
<Input label="Disabled" disabled value="Cannot edit" />
</div>
</section>
{/* Card Demo */}
<section style={{ marginTop: "40px" }}>
<h2>Cards</h2>
<div
style={{
display: "grid",
gap: "16px",
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
}}
>
<Card variant="default">
<Card.Header>Default Card</Card.Header>
<Card.Body>Card content goes here</Card.Body>
<Card.Footer>
<Button size="sm">Action</Button>
</Card.Footer>
</Card>
<Card variant="outlined">
<Card.Header>Outlined Card</Card.Header>
<Card.Body>With border only</Card.Body>
</Card>
<Card variant="elevated">
<Card.Header>Elevated Card</Card.Header>
<Card.Body>With shadow</Card.Body>
</Card>
</div>
</section>
</div>
);
}💡 Solution
// ================================================
// Thiết lập CSS Variables (thêm vào global CSS hoặc :root)
// ================================================
/*
:root {
--color-primary: #0d6efd;
--color-primary-hover: #0b5ed7;
--color-secondary: #6c757d;
--color-secondary-hover: #5a6268;
--color-success: #198754;
--color-success-hover: #157347;
--color-danger: #dc3545;
--color-danger-hover: #bb2d3b;
--color-warning: #ffc107;
--color-warning-hover: #e0a800;
--color-text: #212529;
--color-text-muted: #6c757d;
--color-bg: #ffffff;
--color-bg-muted: #f8f9fa;
--color-border: #dee2e6;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1);
}
*/
// ================================================
// 1. Button Component
// ================================================
import buttonStyles from "./Button.module.css"; // giả sử bạn tạo file này
function Button({
children,
variant = "primary",
size = "md",
loading = false,
disabled = false,
onClick,
...props
}) {
const baseClasses = buttonStyles.button;
const sizeClass = buttonStyles[`size-${size}`];
const variantClass = buttonStyles[variant];
const stateClasses = [
loading && buttonStyles.loading,
disabled && buttonStyles.disabled,
]
.filter(Boolean)
.join(" ");
const className = [baseClasses, sizeClass, variantClass, stateClasses]
.filter(Boolean)
.join(" ");
return (
<button
className={className}
disabled={disabled || loading}
onClick={onClick}
{...props}
>
{loading && <span className={buttonStyles.spinner} aria-hidden="true" />}
<span style={{ visibility: loading ? "hidden" : "visible" }}>
{children}
</span>
</button>
);
}
// Button.defaultProps = {
// variant: 'primary',
// size: 'md',
// loading: false,
// disabled: false,
// };
// ================================================
// 2. Input Component
// ================================================
import inputStyles from "./Input.module.css";
function Input({
type = "text",
label,
error,
disabled = false,
value,
onChange,
id = `input-${Math.random().toString(36).substr(2, 9)}`,
...props
}) {
const wrapperClass = [
inputStyles.wrapper,
error && inputStyles.error,
disabled && inputStyles.disabled,
]
.filter(Boolean)
.join(" ");
return (
<div className={wrapperClass}>
{label && (
<label htmlFor={id} className={inputStyles.label}>
{label}
</label>
)}
<input
id={id}
type={type}
className={inputStyles.input}
disabled={disabled}
value={value}
onChange={onChange}
{...props}
/>
{error && (
<p className={inputStyles.errorMessage} role="alert">
{error}
</p>
)}
</div>
);
}
// ================================================
// 3. Card Compound Component
// ================================================
import cardStyles from "./Card.module.css";
function Card({ children, variant = "default", className = "", ...props }) {
const cardClass = [cardStyles.card, cardStyles[variant], className]
.filter(Boolean)
.join(" ");
return (
<div className={cardClass} {...props}>
{children}
</div>
);
}
Card.Header = function CardHeader({ children, className = "" }) {
return (
<div className={[cardStyles.header, className].filter(Boolean).join(" ")}>
{children}
</div>
);
};
Card.Body = function CardBody({ children, className = "" }) {
return (
<div className={[cardStyles.body, className].filter(Boolean).join(" ")}>
{children}
</div>
);
};
Card.Footer = function CardFooter({ children, className = "" }) {
return (
<div className={[cardStyles.footer, className].filter(Boolean).join(" ")}>
{children}
</div>
);
};
// ================================================
// Demo Page (giữ nguyên cấu trúc của bạn)
// ================================================
function DesignSystemDemo() {
return (
<div style={{ padding: "40px", maxWidth: "800px", margin: "0 auto" }}>
<h1>Design System Demo</h1>
{/* Button Demo */}
<section>
<h2>Buttons</h2>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="success">Success</Button>
<Button variant="danger">Danger</Button>
<Button variant="warning">Warning</Button>
</div>
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
<Button loading>Loading</Button>
<Button disabled>Disabled</Button>
</div>
</section>
{/* Input Demo */}
<section style={{ marginTop: "40px" }}>
<h2>Inputs</h2>
<div style={{ maxWidth: "400px" }}>
<Input label="Name" placeholder="Enter name..." />
<Input label="Email" type="email" placeholder="name@example.com" />
<Input label="Password" type="password" placeholder="••••••••" />
<Input
label="Error Example"
error="This field is required"
placeholder="Type something..."
/>
<Input label="Disabled" disabled value="Cannot edit this" />
</div>
</section>
{/* Card Demo */}
<section style={{ marginTop: "40px" }}>
<h2>Cards</h2>
<div
style={{
display: "grid",
gap: "16px",
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
}}
>
<Card variant="default">
<Card.Header>Default Card</Card.Header>
<Card.Body>
<p>Some quick example text to build on the card title.</p>
</Card.Body>
<Card.Footer>
<Button size="sm" variant="primary">
Action
</Button>
</Card.Footer>
</Card>
<Card variant="outlined">
<Card.Header>Outlined Card</Card.Header>
<Card.Body>
<p>With a nice blue border and minimal shadow.</p>
</Card.Body>
</Card>
<Card variant="elevated">
<Card.Header>Elevated Card</Card.Header>
<Card.Body>
<p>Has stronger shadow for depth effect.</p>
</Card.Body>
</Card>
</div>
</section>
</div>
);
}### README.md (nội dung gợi ý)
# Design System – Basic Components
## Components Overview
- **Button** — Nút đa dạng với 5 variants, 3 kích thước, loading & disabled state
- **Input** — Trường nhập liệu với label, error, disabled, focus state
- **Card** — Card compound (Header / Body / Footer) với 3 variants
## Installation & Setup
1. Đảm bảo CSS Variables được định nghĩa trong `:root` (thường trong `index.css` hoặc `global.css`)
2. Tạo các file CSS Modules tương ứng: `Button.module.css`, `Input.module.css`, `Card.module.css`
## Usage Examples
```jsx
<Button variant="success" size="lg" onClick={handleSave}>
Lưu thay đổi
</Button>
<Input
label="Email"
type="email"
error="Email không hợp lệ"
placeholder="name@example.com"
/>
<Card variant="elevated">
<Card.Header>Thông tin đơn hàng</Card.Header>
<Card.Body>Chi tiết đơn hàng...</Card.Body>
<Card.Footer>
<Button variant="primary">Xác nhận</Button>
</Card.Footer>
</Card>
```## Props API Table
### Button
| Prop | Type | Default | Description |
| -------- | ------- | --------- | -------------------------------------------- |
| variant | string | 'primary' | primary, secondary, success, danger, warning |
| size | string | 'md' | sm, md, lg |
| loading | boolean | false | Hiển thị spinner & disable click |
| disabled | boolean | false | Vô hiệu hóa nút |
### Input
| Prop | Type | Default | Description |
| -------- | ------- | ------- | --------------------------- |
| type | string | 'text' | text, email, password |
| label | string | — | Nhãn phía trên input |
| error | string | — | Thông báo lỗi (hiển thị đỏ) |
| disabled | boolean | false | Vô hiệu hóa input |
### Card
| Prop | Type | Default | Description |
| ------- | ------ | --------- | --------------------------- |
| variant | string | 'default' | default, outlined, elevated |
## Customization Guide
- Thay đổi màu sắc → chỉnh sửa CSS Variables trong `:root`
- Thay đổi khoảng cách → chỉnh `--spacing-*`
- Thay đổi bo góc → chỉnh `--radius-*`
## Accessibility Notes
- Button có `disabled` → screen reader đọc được
- Input có `label` + `htmlFor` → liên kết đúng
- Error message dùng `role="alert"`
- Focus state rõ ràng (outline + shadow)
**Ghi chú cuối:**
- Tất cả component sử dụng **CSS Modules** → scoped, an toàn khi scale
- Dùng **CSS Variables** → dễ theme (light/dark chỉ cần override root)
- Inline styles gần như không dùng (chỉ dùng cho dynamic nếu cần)
- Code clean, comment đủ, không magic number
- Đáp ứng đầy đủ checklist production (loading, disabled, focus, a11y cơ bản)📊 PHẦN 4: SO SÁNH PATTERNS (30 phút)
Bảng So Sánh Trade-offs
| Feature | Inline Styles | CSS Classes | CSS Modules | Tailwind |
|---|---|---|---|---|
| Dynamic values | ⭐⭐⭐⭐⭐ Best | ⭐⭐ Via variables | ⭐⭐ Via variables | ⭐⭐⭐ Conditional |
| Pseudo-classes (:hover) | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes |
| Media queries | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes (md:, lg:) |
| Type safety | ✅ TypeScript | ❌ No | ⭐⭐⭐ Scoped | ⭐⭐ Class names |
| Bundle size | ⭐⭐⭐⭐ JS only | ⭐⭐⭐ Separate CSS | ⭐⭐⭐ Separate CSS | ⭐⭐⭐⭐ Tree-shaken |
| Developer experience | ⭐⭐ Verbose | ⭐⭐⭐⭐ Familiar | ⭐⭐⭐⭐ Clean | ⭐⭐⭐⭐⭐ Fast |
| Naming conflicts | ✅ None | ❌ Global scope | ✅ Scoped | ✅ Utility-based |
| Learning curve | ⭐⭐⭐⭐⭐ Easy | ⭐⭐⭐⭐⭐ Easy | ⭐⭐⭐⭐ Easy | ⭐⭐⭐ Moderate |
| Maintenance | ⭐⭐ Scattered | ⭐⭐⭐ Separate files | ⭐⭐⭐⭐ Colocated | ⭐⭐⭐⭐⭐ Inline |
Decision Tree
Bạn đang style component, cần gì?
1. Style thay đổi dựa trên props/state (màu, size, position)?
├─ Chỉ vài properties → Inline Styles
└─ Nhiều properties → Tailwind với conditional classes
2. Cần CSS features (hover, animations, media queries)?
├─ Simple project, ít components → CSS Classes
├─ Medium project, tránh conflicts → CSS Modules
└─ Rapid development, design system → Tailwind
3. Team preference?
├─ Traditional CSS background → CSS Modules
├─ Utility-first mindset → Tailwind
└─ Minimal setup → Inline + CSS Classes
4. Performance critical?
├─ Need tree-shaking → Tailwind
├─ Minimal CSS → Inline Styles
└─ Standard → Any approach OK
5. Kích thước dự án?
├─ Small (<10 components) → CSS Classes OK
├─ Medium (10-50) → CSS Modules
└─ Large (50+) → Tailwind hoặc CSS ModulesWhen to Use What - Detailed Guide
✅ Dùng Inline Styles khi:
// 1. Dynamic values từ props/state
function ProgressBar({ percent }) {
return (
<div style={{ width: "100%", background: "#eee" }}>
<div
style={{
width: `${percent}%`, // Dynamic!
height: "20px",
background: "#007bff",
transition: "width 0.3s ease",
}}
/>
</div>
);
}
// 2. Animation values
function Draggable({ x, y }) {
return (
<div
style={{
position: "absolute",
left: `${x}px`, // Real-time values
top: `${y}px`,
cursor: "grab",
}}
>
Drag me
</div>
);
}
// 3. Theme colors từ context
function ThemedButton({ theme }) {
return (
<button
style={{
background: theme.primaryColor, // From theme object
color: theme.textColor,
}}
>
Click
</button>
);
}✅ Dùng CSS Classes khi:
// 1. Simple apps không lo conflicts
// App.css
.header { /* ... */ }
.footer { /* ... */ }
// App.jsx
function App() {
return (
<>
<header className="header">Logo</header>
<main>Content</main>
<footer className="footer">© 2024</footer>
</>
);
}
// 2. Global styles (resets, utilities)
// global.css
* { box-sizing: border-box; }
.container { max-width: 1200px; margin: 0 auto; }
.text-center { text-align: center; }✅ Dùng CSS Modules khi:
// 1. Component library (tránh conflicts)
// Button.module.css → Button_primary__x1y2z
// Card.module.css → Card_primary__a3b4c (no conflict!)
// 2. Team lớn, nhiều người cùng code
// Mỗi dev code component riêng với CSS riêng → no conflicts
// 3. Cần CSS features + scoping
import styles from "./Modal.module.css";
function Modal() {
return (
<div className={styles.overlay}>
<div className={styles.modal}>
{/* :hover, :focus, animations đều work */}
</div>
</div>
);
}✅ Dùng Tailwind khi:
// 1. Rapid prototyping
function Prototype() {
return (
<div className="flex items-center gap-4 p-4 bg-white rounded-lg shadow">
<img src="..." className="w-16 h-16 rounded-full" />
<div>
<h3 className="text-lg font-semibold">Name</h3>
<p className="text-gray-600">Description</p>
</div>
</div>
);
}
// 2. Consistent design system
// Tailwind enforces spacing scale (4px, 8px, 16px...)
// No random "padding: 13px" 😅
// 3. Responsive design
function ResponsiveCard() {
return (
<div
className="
w-full // Mobile: full width
md:w-1/2 // Tablet: 50%
lg:w-1/3 // Desktop: 33%
p-4 // All: padding 16px
md:p-6 // Tablet+: padding 24px
"
>
Card
</div>
);
}🧪 PHẦN 5: DEBUG LAB (20 phút)
Bug 1: Inline Styles Object Recreation
// 🐛 BUG: Performance issue
function UserAvatar({ size = 48 }) {
return (
<img
src="/avatar.jpg"
style={{
width: size,
height: size,
borderRadius: "50%",
}} // ❌ New object every render!
/>
);
}Câu hỏi: Tại sao đây là performance issue?
💡 Giải thích
Vấn đề:
- Style object
{ width: size, height: size, borderRadius: '50%' }được tạo mới mỗi render - React so sánh object bằng reference, không phải value
- Mỗi render → object mới → React nghĩ style changed → re-apply styles
Fix 1: useMemo (sẽ học Ngày 33)
import { useMemo } from "react";
function UserAvatar({ size = 48 }) {
const style = useMemo(
() => ({
width: size,
height: size,
borderRadius: "50%",
}),
[size],
); // Only recreate khi size thay đổi
return <img src="/avatar.jpg" style={style} />;
}Fix 2: Hoist static parts (current knowledge)
// ✅ Static style ở ngoài component
const baseStyle = { borderRadius: "50%" };
function UserAvatar({ size = 48 }) {
return (
<img
src="/avatar.jpg"
style={{
...baseStyle, // Reuse object
width: size, // Only dynamic parts inline
height: size,
}}
/>
);
}📚 Vercel Best Practice: Hoist Static JSX
// ❌ Recreates element every render
function Container({ loading }) {
return <div>{loading && <Spinner />}</div>;
}
// ✅ Reuses same element
const spinner = <Spinner />;
function Container({ loading }) {
return <div>{loading && spinner}</div>;
}Bug 2: CSS Modules Dynamic Class Names
// 🐛 BUG: Undefined class
import styles from "./Alert.module.css";
function Alert({ type }) {
// type = "success" | "error" | "warning"
return (
<div className={styles.alert - { type }}>
{" "}
{/* ❌ Syntax error! */}
Alert
</div>
);
}Câu hỏi: Tại sao code này không chạy? Làm sao fix?
💡 Giải thích
Vấn đề:
- Template literals KHÔNG work với object property access
styles.alert-${type}là invalid JavaScript syntax
Fix 1: Bracket notation
function Alert({ type }) {
// ✅ Đúng syntax
const alertClass = styles[`alert-${type}`];
return <div className={alertClass}>Alert</div>;
}Fix 2: CSS class mapping (cleaner)
/* Alert.module.css */
.alert {
/* base */
}
.success {
background: green;
}
.error {
background: red;
}
.warning {
background: yellow;
}function Alert({ type }) {
// ✅ Combine base + variant
return <div className={`${styles.alert} ${styles[type]}`}>Alert</div>;
}Fix 3: Map object (type-safe)
function Alert({ type }) {
const typeStyles = {
success: styles.success,
error: styles.error,
warning: styles.warning,
};
return <div className={`${styles.alert} ${typeStyles[type]}`}>Alert</div>;
}Bug 3: Tailwind Conditional Classes
// 🐛 BUG: Classes không apply
function Badge({ count }) {
return <span className={`badge ${count && "badge-active"}`}>{count}</span>;
}
// <Badge count={0} /> → Renders "0" trong className!
// → className="badge 0"Câu hỏi: Tại sao count=0 lại xuất hiện trong className?
💡 Giải thích
Vấn đề:
count && 'badge-active'vớicount=0→ return0(falsy nhưng vẫn là value)- Template literal convert
0thành string →"badge 0"
Fix: Explicit boolean
// ✅ Cách 1: Ternary
<span className={`badge ${count > 0 ? 'badge-active' : ''}`}>
// ✅ Cách 2: Double boolean
<span className={`badge ${!!count && 'badge-active'}`}>
// ✅ Cách 3: Separate logic (cleanest)
const badgeClass = ['badge', count > 0 && 'badge-active']
.filter(Boolean)
.join(' ');
<span className={badgeClass}>📚 Vercel Best Practice:
// ❌ Dangerous với numbers
{
count && <Badge />;
} // Renders "0" when count=0
// ✅ Explicit boolean
{
count > 0 && <Badge />;
}
{
count > 0 ? <Badge /> : null;
}✅ PHẦN 6: TỰ ĐÁNH GIÁ (15 phút)
Knowledge Check
- [ ] Tôi hiểu 4 cách styling trong React
- [ ] Tôi biết khi nào dùng Inline Styles (dynamic values)
- [ ] Tôi biết khi nào dùng CSS Modules (scoping)
- [ ] Tôi biết khi nào dùng Tailwind (rapid development)
- [ ] Tôi hiểu trade-offs giữa các approaches
- [ ] Tôi biết cách conditional styling với className
- [ ] Tôi biết CSS Modules tránh conflicts như thế nào
- [ ] Tôi biết Tailwind chỉ dùng core classes trong artifactsCode Review Checklist
#### Inline Styles:
- [ ] Static styles được hoist ra ngoài component?
- [ ] Object không bị recreate mỗi render?
- [ ] Chỉ dùng cho dynamic values?
#### CSS Classes:
- [ ] className dùng template literals cho conditional?
- [ ] Tránh dùng && với numbers (0, NaN)?
- [ ] Classes được organize rõ ràng?
#### CSS Modules:
- [ ] File naming convention: `Component.module.css`?
- [ ] Import: `import styles from './Component.module.css'`?
- [ ] Dynamic classes dùng bracket notation?
#### Tailwind:
- [ ] Chỉ dùng core utility classes?
- [ ] Classes được organize (line breaks)?
- [ ] Responsive prefixes (md:, lg:) đúng?
- [ ] Conditional classes với ternary?🏠 BÀI TẬP VỀ NHÀ
Bắt buộc (30 phút)
Bài 1: Style Product List với 3 cách khác nhau
Implement cùng một UI với 3 approaches:
- Version 1: CSS Classes
- Version 2: CSS Modules
- Version 3: Tailwind
UI Spec:
┌──────────────────────────────────────┐
│ Products [Grid View] │
├──────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ Img │ │ Img │ │ Img │ │
│ │ Name │ │ Name │ │ Name │ │
│ │ Price│ │ Price│ │ Price│ │
│ │ [Buy]│ │ [Buy]│ │ [Buy]│ │
│ └──────┘ └──────┘ └──────┘ │
└──────────────────────────────────────┘Requirements:
- Grid layout (3 columns)
- Card hover effect (shadow)
- Button hover (darker)
- Responsive: mobile = 1 column
Compare: Viết nhận xét về DX, code length, maintainability của từng cách.
💡 Solution
// Dữ liệu mẫu dùng chung cho cả 3 version
const products = [
{ id: 1, name: "iPhone 16 Pro", price: 34990000, image: "https://picsum.photos/seed/iphone/300/300" },
{ id: 2, name: "MacBook Pro M4", price: 49990000, image: "https://picsum.photos/seed/macbook/300/300" },
{ id: 3, name: "AirPods Max", price: 14990000, image: "https://picsum.photos/seed/airpods/300/300" },
{ id: 4, name: "Apple Watch Ultra 2", price: 21990000, image: "https://picsum.photos/seed/watch/300/300" },
];
// ────────────────────────────────────────────────
// VERSION 1: CSS Classes (global CSS)
// ────────────────────────────────────────────────
/* File: ProductGrid.css (global) */
.product-grid-container {
max-width: 1400px;
margin: 0 auto;
padding: 32px 16px;
}
.product-grid-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 24px;
text-align: center;
}
.product-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
@media (max-width: 1024px) {
.product-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.product-grid {
grid-template-columns: 1fr;
}
}
.product-card {
background: white;
border-radius: 12px;
overflow: hidden;
border: 1px solid #e5e7eb;
transition: all 0.25s ease;
}
.product-card:hover {
transform: translateY(-6px);
box-shadow: 0 10px 20px rgba(0,0,0,0.12);
}
.product-image {
width: 100%;
height: 240px;
object-fit: cover;
}
.product-info {
padding: 16px;
}
.product-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.4;
}
.product-price {
font-size: 1.25rem;
font-weight: 700;
color: #2563eb;
margin-bottom: 12px;
}
.buy-button {
width: 100%;
padding: 10px;
background: #2563eb;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.buy-button:hover {
background: #1d4ed8;
}
function ProductGridCSSClasses() {
return (
<div className="product-grid-container">
<h2 className="product-grid-title">Products (CSS Classes)</h2>
<div className="product-grid">
{products.map(product => (
<div key={product.id} className="product-card">
<img src={product.image} alt={product.name} className="product-image" />
<div className="product-info">
<h3 className="product-name">{product.name}</h3>
<p className="product-price">{product.price.toLocaleString('vi-VN')}₫</p>
<button className="buy-button">Mua ngay</button>
</div>
</div>
))}
</div>
</div>
);
}
// ────────────────────────────────────────────────
// VERSION 2: CSS Modules
// ────────────────────────────────────────────────
/* File: ProductGrid.module.css */
.gridContainer {
max-width: 1400px;
margin: 0 auto;
padding: 32px 16px;
}
.title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 24px;
text-align: center;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
@media (max-width: 1024px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
.card {
background: white;
border-radius: 12px;
overflow: hidden;
border: 1px solid #e5e7eb;
transition: all 0.25s ease;
}
.card:hover {
transform: translateY(-6px);
box-shadow: 0 10px 20px rgba(0,0,0,0.12);
}
.image {
width: 100%;
height: 240px;
object-fit: cover;
}
.info {
padding: 16px;
}
.name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 8px;
line-height: 1.4;
}
.price {
font-size: 1.25rem;
font-weight: 700;
color: #2563eb;
margin-bottom: 12px;
}
.button {
width: 100%;
padding: 10px;
background: #2563eb;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.button:hover {
background: #1d4ed8;
}
import styles from './ProductGrid.module.css';
function ProductGridCSSModules() {
return (
<div className={styles.gridContainer}>
<h2 className={styles.title}>Products (CSS Modules)</h2>
<div className={styles.grid}>
{products.map(product => (
<div key={product.id} className={styles.card}>
<img src={product.image} alt={product.name} className={styles.image} />
<div className={styles.info}>
<h3 className={styles.name}>{product.name}</h3>
<p className={styles.price}>{product.price.toLocaleString('vi-VN')}₫</p>
<button className={styles.button}>Mua ngay</button>
</div>
</div>
))}
</div>
</div>
);
}
// ────────────────────────────────────────────────
// VERSION 3: Tailwind CSS
// ────────────────────────────────────────────────
function ProductGridTailwind() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h2 className="text-3xl font-bold text-gray-900 mb-10 text-center">
Products (Tailwind)
</h2>
<div className="
grid grid-cols-1
sm:grid-cols-2
lg:grid-cols-3
gap-6 md:gap-8
">
{products.map(product => (
<div
key={product.id}
className="
bg-white rounded-xl overflow-hidden border border-gray-200
transition-all duration-300
hover:shadow-2xl hover:-translate-y-2 hover:border-gray-300
"
>
<img
src={product.image}
alt={product.name}
className="w-full h-60 object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="p-5">
<h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-2">
{product.name}
</h3>
<p className="text-xl font-bold text-blue-600 mb-4">
{product.price.toLocaleString('vi-VN')}₫
</p>
<button className="
w-full py-3 px-4 bg-blue-600 text-white font-semibold
rounded-lg hover:bg-blue-700 active:bg-blue-800
transition-colors duration-200
">
Mua ngay
</button>
</div>
</div>
))}
</div>
</div>
);
}So sánh 3 cách tiếp cận (DX, code length, maintainability)
| Tiêu chí | Version 1: CSS Classes (global) | Version 2: CSS Modules | Version 3: Tailwind CSS |
|---|---|---|---|
| Code length (JSX) | Ngắn, dễ đọc | Ngắn, rất sạch (className ngắn) | Dài nhất (nhiều class), nhưng cấu trúc rõ ràng |
| Code length (CSS) | Dài, phải đặt tên class thủ công | Dài, nhưng scoped → an toàn | Không cần file CSS → ngắn nhất về tổng thể |
| Developer Experience (DX) | Quen thuộc với dev truyền thống | Tốt nhất về scoping & tổ chức | Nhanh nhất khi viết, không cần chuyển tab |
| Maintainability | Dễ xung đột khi dự án lớn | Tốt nhất (scoped, colocation) | Rất tốt (utility classes dễ tìm, nhất quán) |
| Responsive | Cần viết media query thủ công | Cần viết media query thủ công | Siêu tiện (sm:, md:, lg:,...) |
| Dynamic styling | Dễ kết hợp template literals | Dễ với bracket notation | Rất tốt với template literals + điều kiện |
| Khi nào dùng | Dự án rất nhỏ, team nhỏ, không lo conflict | Dự án trung bình/lớn, component library | Dự án cần tốc độ, design system, prototype nhanh |
| Điểm tổng (cho dashboard admin) | 6/10 | 8.5/10 | 9/10 (lựa chọn tốt nhất hiện nay) |
Kết luận ngắn gọn:
- CSS Classes → phù hợp học tập, dự án rất nhỏ
- CSS Modules → lựa chọn an toàn nhất cho dự án trung bình/lớn (scoped, dễ maintain lâu dài)
- Tailwind → nhanh nhất về phát triển, dễ mở rộng responsive/dark mode, được cộng đồng hiện đại ưa chuộng nhất cho dashboard & SaaS admin panel năm 2025–2026
Nếu là tech lead startup, Tailwind thường là lựa chọn thắng thế trong hầu hết các trường hợp hiện nay (đặc biệt khi team < 10 người và cần iterate nhanh).
Nâng cao (60 phút)
Bài 2: Theme Switcher Component
Build component toggle giữa Light/Dark theme:
/**
* Requirements:
* 1. Button toggle theme
* 2. Apply theme cho toàn bộ app
* 3. Persist theme (localStorage - đã học Ngày 7)
* 4. Smooth transition giữa themes
*
* Technical:
* - Dùng CSS variables cho colors
* - Inline styles cho dynamic theme
* - localStorage cho persistence
*/Gợi ý structure:
// Theme object
const themes = {
light: {
background: "#ffffff",
text: "#000000",
primary: "#007bff",
},
dark: {
background: "#1a1a1a",
text: "#ffffff",
primary: "#0dcaf0",
},
};
// Component
function App() {
const [theme, setTheme] = useState("light");
// TODO:
// 1. Load theme từ localStorage
// 2. Apply theme styles
// 3. Save khi theme changes
return (
<div
style={{
background: themes[theme].background,
color: themes[theme].text,
}}
>
{/* Your UI */}
</div>
);
}💡 Solution
/**
* ThemeSwitcher - Component toggle Light/Dark theme với persistence & transition mượt
*
* Features:
* - Nút toggle theme (☀️ / 🌙)
* - Áp dụng theme toàn app qua CSS variables
* - Lưu theme vào localStorage (persist khi refresh)
* - Transition mượt giữa các theme
* - Responsive & clean UI demo
*/
function ThemeSwitcher() {
// Khởi tạo theme từ localStorage (hoặc mặc định 'light')
const [theme, setTheme] = React.useState(() => {
const saved = localStorage.getItem("theme");
return saved || "light";
});
// Áp dụng theme lên :root và lưu vào localStorage khi theme thay đổi
React.useEffect(() => {
// Lưu vào localStorage
localStorage.setItem("theme", theme);
// Cập nhật data-theme attribute để dễ override CSS nếu cần
document.documentElement.setAttribute("data-theme", theme);
// Optional: thêm class 'dark' cho tailwind dark mode (nếu dùng tailwind)
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}, [theme]);
// Toggle function
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
// Theme definitions (dùng CSS variables)
// Bạn có thể mở rộng thêm nhiều biến hơn
const themeStyles = {
light: {
"--bg-primary": "#ffffff",
"--bg-secondary": "#f8f9fa",
"--text-primary": "#212529",
"--text-secondary": "#495057",
"--primary": "#0d6efd",
"--primary-hover": "#0b5ed7",
"--border": "#dee2e6",
"--shadow": "0 4px 6px -1px rgba(0,0,0,0.1)",
},
dark: {
"--bg-primary": "#121212",
"--bg-secondary": "#1e1e1e",
"--text-primary": "#e0e0e0",
"--text-secondary": "#9e9e9e",
"--primary": "#0dcaf0",
"--primary-hover": "#0aa2c0",
"--border": "#333333",
"--shadow": "0 4px 6px -1px rgba(0,0,0,0.5)",
},
};
// Áp dụng CSS variables động (chạy mỗi khi theme thay đổi)
React.useEffect(() => {
const root = document.documentElement;
Object.entries(themeStyles[theme]).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
}, [theme]);
// Thêm transition mượt cho toàn bộ trang
React.useEffect(() => {
document.documentElement.style.transition =
"background-color 0.4s ease, color 0.4s ease";
}, []);
return (
<div
className="min-h-screen transition-colors duration-400"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
}}
>
{/* Header */}
<header
className="sticky top-0 z-10 border-b shadow-sm"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border)",
}}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold">Theme Switcher Demo</h1>
<button
onClick={toggleTheme}
className="
px-5 py-2.5 rounded-full font-medium
transition-all duration-300 transform hover:scale-105
flex items-center gap-2
"
style={{
backgroundColor: "var(--primary)",
color: "white",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--primary-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "var(--primary)";
}}
>
{theme === "light" ? (
<>🌙 Chuyển sang Dark Mode</>
) : (
<>☀️ Chuyển sang Light Mode</>
)}
</button>
</div>
</header>
{/* Main content demo */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="
rounded-xl p-6 shadow-md
transition-all duration-300 hover:shadow-xl hover:-translate-y-1
"
style={{
backgroundColor: "var(--bg-secondary)",
border: "1px solid var(--border)",
}}
>
<h3 className="text-xl font-semibold mb-3">Card {i + 1}</h3>
<p className="mb-4" style={{ color: "var(--text-secondary)" }}>
Đây là nội dung mẫu để thể hiện theme. Màu nền, chữ, viền sẽ tự
động thay đổi khi chuyển theme.
</p>
<button
className="
px-4 py-2 rounded-lg font-medium text-white
transition-colors duration-200
"
style={{
backgroundColor: "var(--primary)",
}}
>
Action
</button>
</div>
))}
</div>
</main>
{/* Footer */}
<footer
className="border-t py-6 text-center text-sm"
style={{
borderColor: "var(--border)",
color: "var(--text-secondary)",
}}
>
<p>
Theme được lưu trong localStorage • Ngày hiện tại:{" "}
{new Date().toLocaleDateString("vi-VN")}
</p>
</footer>
</div>
);
}Hướng dẫn sử dụng & kiểm tra:
- Copy component
ThemeSwitchervào file và render trong App - Click nút toggle → theme thay đổi mượt (transition 0.4s)
- Refresh trang → theme vẫn giữ nguyên (do localStorage)
- Kiểm tra DevTools → xem
:rootcó các--bg-primary,--text-primary, v.v. thay đổi đúng
Lưu ý kỹ thuật:
- Sử dụng
data-themeattribute + CSS variables → dễ mở rộng dark mode với Tailwind (nếu sau này dùng) - Transition áp dụng cho
background-colorvàcolor→ mượt mà nhất - Loading state không cần vì theme switch rất nhanh
- Responsive: layout grid tự động điều chỉnh theo kích thước màn hình
Khi học thêm prefers-color-scheme, bạn có thể thêm:
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const initialTheme = saved || (prefersDark ? "dark" : "light");📚 TÀI LIỆU THAM KHẢO
Bắt buộc đọc
React Docs - Styling:
- https://react.dev/learn/styling-react-components
- Focus: Inline styles, className, CSS Modules
CSS Modules Official:
- https://github.com/css-modules/css-modules
- Hiểu scoping mechanism
Tailwind CSS Docs (Core Utilities):
- https://tailwindcss.com/docs/utility-first
- Chỉ đọc Utilities section (skip config)
Đọc thêm
Vercel Best Practices:
- Conditional Rendering (avoiding 0, NaN)
- SVG Performance (animate wrapper)
- Hoisting static JSX
CSS Performance:
- https://web.dev/css-performance/
- Critical CSS, content-visibility
Accessibility:
- https://www.w3.org/WAI/WCAG21/quickref/
- Focus visible, color contrast
🔗 KẾT NỐI KIẾN THỨC
Kiến thức nền (đã học):
- Ngày 3: JSX syntax → className attribute
- Ngày 4: Props → truyền variant, size
- Ngày 5: Conditional rendering → conditional classes
- Ngày 6: Lists → render multiple styled items
- Ngày 7: Component composition → Card.Header pattern
Hướng tới (sẽ dùng):
- Ngày 11: useState → dynamic theme switching
- Ngày 16: useEffect → detect dark mode preference
- Ngày 21: useRef → animate element measurements
- Ngày 33: useMemo → optimize style calculations
- Ngày 46: useTransition → smooth theme transitions
- Module C (Ngày 91-95): Deep dive Styling (styled-components, Emotion)
💡 SENIOR INSIGHTS
Cân Nhắc Production
1. Bundle Size Impact:
// ❌ Import toàn bộ icon library
import { Icon } from "huge-icon-library"; // 500KB!
// ✅ Import chỉ icon cần (tree-shaking)
import CheckIcon from "huge-icon-library/icons/check"; // 2KB
// 📊 Impact: 250× nhỏ hơn2. CSS Specificity Wars:
/* ❌ Specificity hell */
.card .header .title {
}
.card.featured .header .title {
} /* Must override */
.card.featured.large .header .title {
} /* More specific */
/* ✅ Flat structure (BEM hoặc CSS Modules) */
.card__title {
}
.card__title--featured {
}
.card__title--large {
}3. Runtime vs Build-time Styles:
// Runtime: CSS-in-JS (styled-components)
// - Pros: Dynamic, theme-aware
// - Cons: JS bundle, runtime overhead
// Build-time: CSS Modules, Tailwind
// - Pros: Optimized, tree-shaken
// - Cons: Less dynamic
// 📊 Real numbers from Vercel blog:
// CSS-in-JS: +40KB JS, runtime parse
// Tailwind: -60% unused CSS with PurgeCSS4. Dark Mode Strategy:
// ❌ Inline styles (hard to maintain)
<div style={{ background: isDark ? '#000' : '#fff' }}>
// ❌ Duplicate CSS
.light-theme .card { background: white; }
.dark-theme .card { background: black; }
// ✅ CSS Variables (best)
:root {
--bg: white;
--text: black;
}
[data-theme="dark"] {
--bg: black;
--text: white;
}
.card { background: var(--bg); color: var(--text); }Câu Hỏi Phỏng Vấn
Junior Level:
Q1: "So sánh inline styles và CSS classes trong React?"
Expected Answer
- Inline styles: JSX object, camelCase, good for dynamic values, no pseudo-classes
- CSS classes: string className, kebab-case in CSS, full CSS power, potential conflicts
- Trade-off: Dynamic → inline, Static → CSS
Q2: "CSS Modules giải quyết vấn đề gì?"
Expected Answer
- Scoping: Class names được hash, tránh conflicts
- Import như JS module:
import styles from './file.module.css' - Vẫn dùng CSS features (hover, media queries)
Mid Level:
Q3: "Khi nào nên dùng CSS-in-JS thay vì CSS Modules?"
Expected Answer
CSS-in-JS (styled-components) khi:
- Cần dynamic styles based on complex props
- Theme switching frequency cao
- Component library với many variants
CSS Modules khi:
- Performance critical (no runtime)
- Team familiar với traditional CSS
- Static styles chủ yếu
Q4: "Optimize performance của inline styles thế nào?"
Expected Answer
- Hoist static parts bên ngoài component
- useMemo cho complex calculations (sẽ học Ngày 33)
- Avoid recreating object mỗi render
- Consider CSS Classes nếu không truly dynamic
Senior Level:
Q5: "Architecture decision: Tailwind vs CSS Modules cho app 100+ components?"
Expected Answer
Analysis:
Tailwind:
- Pros: Consistent design tokens, fast iteration, tree-shakeable
- Cons: Learning curve, HTML clutter, hard to enforce custom brand
CSS Modules:
- Pros: Full CSS control, team familiar, clear separation
- Cons: Naming overhead, potential duplication, larger CSS bundle
Decision Framework:
- Has design system? → Tailwind (enforces constraints)
- Custom/branded UI? → CSS Modules (full control)
- Team size >10? → Tailwind (consistency)
- Timeline tight? → Tailwind (faster)
Hybrid Approach:
- Tailwind for layout/utilities
- CSS Modules for complex components
- CSS Variables for theming
War Stories
Story 1: The className="0" Bug
// Production bug tại Vercel Dashboard (real story)
// ❌ Original code
function Badge({ count }) {
return (
<span className={`badge ${count && 'has-count'}`}>
{count}
</span>
);
}
// User có 0 notifications → HTML: <span className="badge 0">0</span>
// CSS selector .badge.0 failed → no styles applied!
// ✅ Fix
<span className={`badge ${count > 0 ? 'has-count' : ''}`}>Lesson: Always explicit booleans với numbers
Story 2: CSS Modules Hash Collision
// Extremely rare, but happened:
// ComponentA.module.css → .button__a1b2
// ComponentB.module.css → .button__a1b2 (same hash!)
// Root cause: Identical content + webpack config
// Fix: Update webpack css-loader hashStrategyLesson: CSS Modules không 100% collision-free (though extremely rare)
Story 3: Tailwind Purge Removed Needed Classes
// Dynamic class names bị purge (removed from production build)
// ❌ This won't work
const colors = ['red', 'blue', 'green'];
<div className={`bg-${colors[index]}-500`}> // Purged!
// ✅ Safelist hoặc use full names
<div className={
index === 0 ? 'bg-red-500' :
index === 1 ? 'bg-blue-500' :
'bg-green-500'
}>Lesson: Tailwind PurgeCSS scans code statically - no dynamic strings
🎓 TỔNG KẾT NGÀY 8
Bạn đã học được:
✅ 4 cách styling: Inline, CSS Classes, CSS Modules, Tailwind ✅ Decision tree: Khi nào dùng cách nào ✅ Trade-offs: Không có perfect solution ✅ Production patterns: Hoisting, CSS vars, conditional classes ✅ Vercel best practices: Conditional rendering, static hoisting, SVG optimization
Key Takeaways:
- Inline Styles: Dynamic values only, hoist static parts
- CSS Classes: Simple apps, watch out for conflicts
- CSS Modules: Scoped styles, full CSS power, best for medium+ apps
- Tailwind: Utility-first, rapid development, enforce design system
Ngày mai (Ngày 9):
Forms Controlled - Part 1 (KHÔNG STATE)
Chúng ta sẽ học:
- Form elements trong React
- Controlled vs Uncontrolled concept (lý thuyết)
- Event.target.value
- Form validation cơ bản
⚠️ Chú ý: Ngày 9 chỉ dạy CONCEPT của controlled forms, CHƯA implement (vì chưa học useState). Implementation thực tế sẽ là Ngày 13 (sau khi học useState patterns).