Перейти к содержимому

15. Шрифты и стили

Шрифты и стили — это то, что превращает скучный HTML в красивый продукт. В Next.js есть несколько способов работать со стилями: CSS Modules, Tailwind CSS, CSS-in-JS — и мощный next/font для шрифтов без проблем с производительностью. Разберём каждый! 🚀


Обычная загрузка Google Fonts: браузер сначала рендерит резервный шрифт, потом загружает нужный — происходит Flash Of Unstyled Text (FOUT). next/font решает это:

app/layout.tsx
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);
}

Для кастомных или коммерческих шрифтов используй локальную загрузку:

app/layout.tsx
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 — файлы .module.css, стили которых автоматически скоупируются к компоненту (уникальные имена классов):

components/Card/Card.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);
}
components/Card/Card.tsx
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 — самый популярный выбор для Next.js. Устанавливается одной командой:

Окно терминала
npx create-next-app@latest --tailwind
npm install tailwindcss @tailwindcss/postcss postcss
tailwind.config.ts
import 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:

// Кнопка на Tailwind
export 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>
);
}

app/globals.css
: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 библиотеки (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!

app/layout.tsx
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>
);
}