15. Шрифты и стили
🎨 Шрифты и стили: красота без боли
Заголовок раздела «🎨 Шрифты и стили: красота без боли»Шрифты и стили — это то, что превращает скучный HTML в красивый продукт. В Next.js есть несколько способов работать со стилями: CSS Modules, Tailwind CSS, CSS-in-JS — и мощный next/font для шрифтов без проблем с производительностью. Разберём каждый! 🚀
🔤 next/font/google: шрифты без FOUT
Заголовок раздела «🔤 next/font/google: шрифты без FOUT»Обычная загрузка Google Fonts: браузер сначала рендерит резервный шрифт, потом загружает нужный — происходит Flash Of Unstyled Text (FOUT). next/font решает это:
import { Inter, Roboto_Mono, Playfair_Display } from 'next/font/google';
// Next.js загружает шрифт во время BUILD, а не в браузере!const inter = Inter({ subsets: ['latin', 'cyrillic'], // только нужные символы variable: '--font-inter', // CSS переменная display: 'swap', // font-display: swap preload: true, // предзагрузка weight: ['400', '500', '600', '700'], // только нужные начертания});
const robotoMono = Roboto_Mono({ subsets: ['latin'], variable: '--font-mono', display: 'swap',});
const playfair = Playfair_Display({ subsets: ['latin', 'cyrillic'], variable: '--font-playfair', weight: ['400', '700'], style: ['normal', 'italic'],});
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ru" // Применяем CSS переменные к html className={`${inter.variable} ${robotoMono.variable} ${playfair.variable}`} > <body className={inter.className}> {/* Inter как основной шрифт */} {children} </body> </html> );}/* globals.css — используем переменные */:root { --font-sans: var(--font-inter); --font-mono: var(--font-mono); --font-display: var(--font-playfair);}
body { font-family: var(--font-sans, system-ui, sans-serif);}
code, pre { font-family: var(--font-mono, 'Courier New', monospace);}
h1, h2 { font-family: var(--font-display, Georgia, serif);}📁 next/font/local: локальные шрифты
Заголовок раздела «📁 next/font/local: локальные шрифты»Для кастомных или коммерческих шрифтов используй локальную загрузку:
import localFont from 'next/font/local';
// Один начертаниеconst myFont = localFont({ src: './fonts/MyFont-Regular.woff2', variable: '--font-my', display: 'swap',});
// Несколько начертанийconst geist = localFont({ src: [ { path: './fonts/GeistVF.woff2', weight: '100 900', // variable font — одним файлом! style: 'normal', }, { path: './fonts/GeistMonoVF.woff2', weight: '100 900', style: 'normal', }, ], variable: '--font-geist', display: 'swap',});
// Несколько отдельных файловconst montserrat = localFont({ src: [ { path: './fonts/Montserrat-Regular.woff2', weight: '400', style: 'normal' }, { path: './fonts/Montserrat-Italic.woff2', weight: '400', style: 'italic' }, { path: './fonts/Montserrat-Bold.woff2', weight: '700', style: 'normal' }, { path: './fonts/Montserrat-BoldItalic.woff2', weight: '700', style: 'italic' }, ], variable: '--font-montserrat', display: 'swap',});
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="ru" className={`${geist.variable} ${montserrat.variable}`}> <body>{children}</body> </html> );}🎯 CSS Modules: скоупированные стили
Заголовок раздела «🎯 CSS Modules: скоупированные стили»CSS Modules — файлы .module.css, стили которых автоматически скоупируются к компоненту (уникальные имена классов):
.card { background: var(--color-surface); border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: transform 0.2s, box-shadow 0.2s;}
.card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);}
.title { font-size: 1.25rem; font-weight: 700; color: var(--color-text-primary); margin-bottom: 8px;}
.description { color: var(--color-text-secondary); line-height: 1.6;}
/* Композиция — наследуем стили из другого модуля */.cardFeatured { composes: card; border: 2px solid var(--color-accent);}import styles from './Card.module.css';import clsx from 'clsx'; // или classnames
interface CardProps { title: string; description: string; featured?: boolean; className?: string;}
export function Card({ title, description, featured, className }: CardProps) { return ( <div className={clsx( featured ? styles.cardFeatured : styles.card, className )} > <h3 className={styles.title}>{title}</h3> <p className={styles.description}>{description}</p> </div> );}
// В результирующем HTML: <div class="Card_card__xK9pQ">// Имена автоматически уникальны — нет конфликтов!🌊 Tailwind CSS: utility-first стили
Заголовок раздела «🌊 Tailwind CSS: utility-first стили»Tailwind — самый популярный выбор для Next.js. Устанавливается одной командой:
npx create-next-app@latest --tailwindnpm install tailwindcss @tailwindcss/postcss postcssimport type { Config } from 'tailwindcss';
const config: Config = { content: [ './pages/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}', ],
// Расширяем тему theme: { extend: { colors: { brand: { 50: '#f0fdf4', 500: '#22c55e', 900: '#14532d', }, }, fontFamily: { sans: ['var(--font-inter)', 'system-ui', 'sans-serif'], mono: ['var(--font-mono)', 'Courier New', 'monospace'], }, animation: { 'fade-in': 'fadeIn 0.3s ease-in-out', 'slide-up': 'slideUp 0.4s ease-out', }, keyframes: { fadeIn: { '0%': { opacity: '0' }, '100%': { opacity: '1' }, }, slideUp: { '0%': { transform: 'translateY(20px)', opacity: '0' }, '100%': { transform: 'translateY(0)', opacity: '1' }, }, }, }, },
plugins: [ require('@tailwindcss/typography'), // prose классы для MDX require('@tailwindcss/forms'), // красивые формы ],};
export default config;Использование с Tailwind:
// Кнопка на Tailwindexport function Button({ variant = 'primary', children, ...props }: ButtonProps) { const variants = { primary: 'bg-blue-600 hover:bg-blue-700 text-white', secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-900', danger: 'bg-red-600 hover:bg-red-700 text-white', ghost: 'hover:bg-gray-100 text-gray-700', };
return ( <button className={` inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} `} {...props} > {children} </button> );}🎨 CSS Variables + Dark Mode
Заголовок раздела «🎨 CSS Variables + Dark Mode»:root { /* Light theme */ --color-bg: #ffffff; --color-surface: #f8fafc; --color-text-primary: #0f172a; --color-text-secondary: #64748b; --color-accent: #0ea5e9; --color-border: #e2e8f0;}
[data-theme="dark"] { --color-bg: #0f172a; --color-surface: #1e293b; --color-text-primary: #f1f5f9; --color-text-secondary: #94a3b8; --color-accent: #38bdf8; --color-border: #334155;}
/* Автоматическая тема по системным настройкам */@media (prefers-color-scheme: dark) { :root:not([data-theme]) { --color-bg: #0f172a; --color-surface: #1e293b; /* ... */ }}⚠️ CSS-in-JS и Server Components
Заголовок раздела «⚠️ CSS-in-JS и Server Components»CSS-in-JS библиотеки (styled-components, emotion) не работают в Server Components без дополнительной настройки, потому что используют React Context:
// ❌ Это НЕ работает в Server Components:import styled from 'styled-components';
// styled-components использует React Context → только в Client Components!const StyledDiv = styled.div` color: red;`;
// ✅ Работает: используй CSS Modules или Tailwind в Server Components// ✅ Работает: CSS-in-JS только в Client Components
'use client';import styled from 'styled-components';
export function ClientStyledComponent() { const StyledButton = styled.button` background: blue; color: white; `; return <StyledButton>Клик</StyledButton>;}Если нужен CSS-in-JS + RSC, используй StyleX (от Meta) или vanilla-extract — они генерируют статический CSS во время сборки!
🌍 Глобальные стили
Заголовок раздела «🌍 Глобальные стили»// app/globals.css — единственное место для глобальных стилей@tailwind base;@tailwind components;@tailwind utilities;
/* Базовые сбросы */*, *::before, *::after { box-sizing: border-box;}
html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;}
/* Кастомный скроллбар */::-webkit-scrollbar { width: 6px;}
::-webkit-scrollbar-track { background: var(--color-bg);}
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px;}
/* Tailwind компоненты */@layer components { .container { @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; }
.card { @apply bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700; }
.btn-primary { @apply inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors; }}
/* app/layout.tsx */import './globals.css'; // Импорт глобальных стилей ТОЛЬКО в root layout!📝 Пример: полный Layout с шрифтами и темой
Заголовок раздела «📝 Пример: полный Layout с шрифтами и темой»import type { Metadata } from 'next';import { Inter, JetBrains_Mono } from 'next/font/google';import localFont from 'next/font/local';import './globals.css';
const inter = Inter({ subsets: ['latin', 'cyrillic'], variable: '--font-sans', display: 'swap',});
const jetbrains = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono', display: 'swap',});
export const metadata: Metadata = { title: { template: '%s | МойСайт', default: 'МойСайт' }, description: 'Описание сайта',};
export default function RootLayout({ children,}: { children: React.ReactNode;}) { return ( <html lang="ru" className={`${inter.variable} ${jetbrains.variable}`} suppressHydrationWarning // для смены темы > <body className="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 font-sans"> {children} </body> </html> );}