上一节已经让 /、/books 与 /about 各自找到正确的 Page。现在要解决另一个问题:这些页面怎样看起来属于同一个产品,而不是几份互不相干的练习。
我们会先建立颜色、字体与间距的共同语言,再把页头和页脚交给根布局,最后完成一张不依赖图书数据的静态首页。这样打开首页时,视觉结构已经完整;等后面接入数据,只需替换内容来源,不必推倒页面骨架。
create-next-app 已经安装 Tailwind CSS 4。它从 globals.css 中的 @import "tailwindcss" 开始工作,并扫描组件里出现的类名,例如 px-5、rounded-3xl 和 lg:grid-cols-2,再生成实际需要的 CSS。
尺寸类可以直接使用,但品牌颜色不应该散落成一串十六进制值。我们希望组件表达的是“纸张背景”“墨色文字”“珊瑚强调”,而不是记住 #f7f2e8 或 #f29f85。这类有明确角色的变量叫作设计令牌。
Tailwind CSS 4 可以在 @theme inline 中把 CSS 变量映射成工具类。--color-paper 会生成 bg-paper、text-paper 等类;--color-ink 会生成 text-ink、border-ink 等类。日后调整配色,只需要修改变量,组件结构不必跟着改。
把 src/app/globals.css 替换为下面的内容:
@import "tailwindcss";
:root {
--paper: #f7f2e8;
--ink: #1f2933;
--coral: #f29f85;
--sage: #b8d8c0;
--blue: #a9c9df;
--amber: #f3ca76;
--violet: #c9b6df;
--navy: #8fa7c6;
}
@theme inline {
--color-paper: var(--paper);
--color-ink: var(--ink);
--color-coral: var(--coral);
--color-sage: var(--sage);
--color-blue: var(--blue);
--color-amber: var(--amber);
--color-violet: var(--violet);
--color-navy: var(--navy);
--color-sage-ink: #2f6544;
--font-sans: var(--font-geist-sans), "PingFang SC", "Microsoft YaHei", sans-serif;
--font-mono: var(--font-geist-mono), monospace;
--font-serif: "Songti SC", "STSong", "Noto Serif CJK SC", serif;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: var(--font-geist-sans), "PingFang SC", "Microsoft YaHei", sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
::selection {
background: var(--amber);
color: var(--ink);
}
.hero-grid {
background-color: var(--paper);
background-image:
linear-gradient(rgba(31, 41, 51, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(31, 41, 51, 0.045) 1px, transparent 1px);
background-size: 34px 34px;
}
.eyebrow {
color: rgb(31 41 51 / 0.56);
font-size: 0.75rem;
font-weight: 750;
letter-spacing: 0.18em;
}
.button-primary,
.button-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0.8rem 1.25rem;
font-size: 0.9rem;
font-weight: 750;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.button-primary {
border: 1.5px solid var(--ink);
background: var(--ink);
color: white;
box-shadow: 3px 4px 0 var(--coral);
}
.button-primary:hover {
transform: translateY(-2px);
box-shadow: 4px 6px 0 var(--coral);
}
.button-secondary {
border: 1.5px solid rgb(31 41 51 / 0.2);
background: rgb(255 255 255 / 0.72);
color: var(--ink);
}
.button-secondary:hover {
border-color: var(--ink);
background: white;
}
.label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.85rem;
font-weight: 700;
}
.field {
width: 100%;
border: 1px solid rgb(31 41 51 / 0.16);
border-radius: 14px;
outline: none;
background: var(--paper);
padding: 0.78rem 0.9rem;
color: var(--ink);
}
.field:focus {
border-color: var(--ink);
box-shadow: 0 0 0 3px rgb(242 159 133 / 0.35);
}
.field-error {
margin-top: 0.4rem;
color: #b42318;
font-size: 0.78rem;
}
.cover {
color: var(--ink);
background-image:
radial-gradient(circle at 18% 18%, rgb(255 255 255 / 0.55) 0 3px, transparent 4px),
repeating-linear-gradient(112deg, transparent 0 21px, rgb(31 41 51 / 0.045) 22px 23px);
}
.cover::after {
position: absolute;
inset: 10px;
border: 1px solid rgb(31 41 51 / 0.2);
border-radius: inherit;
content: "";
pointer-events: none;
}
.cover-coral { background-color: var(--coral); }
.cover-sage { background-color: var(--sage); }
.cover-blue { background-color: var(--blue); }
.cover-amber { background-color: var(--amber); }
.cover-violet { background-color: var(--violet); }
.cover-navy { background-color: var(--navy); }
.prose-copy {
color: rgb(31 41 51 / 0.72);
font-size: 1.08rem;
line-height: 1.95;
}
.prose-copy p + p {
margin-top: 1.25rem;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}@import "tailwindcss" 是 Tailwind CSS 4 的入口。开发与构建过程中,Tailwind 会扫描组件源码里的完整类名并生成对应规则;删除这行以后,flex、px-5 等工具类就不会生效。它不是浏览器发出的第二次网络请求,而是 CSS 构建流程的一部分。
:root 中的 --paper、--ink 等变量属于浏览器可以求值的原始设计令牌,@theme inline 再把它们登记进 Tailwind 的主题命名空间。例如 --color-paper 会让组件可以写 bg-paper、text-paper 与 border-paper。数据流是“原始色值 → 语义变量 → Tailwind 工具类 → 组件 className”,所以换主题时只改起点,不必搜索所有组件。
字体也走同一条链路。--font-sans 优先读取根布局稍后提供的 --font-geist-sans,中文没有 Geist 字形时再回退到苹方、微软雅黑和系统无衬线字体。变量可以引用稍后才挂到祖先元素上的变量,最终值会在浏览器计算样式时解析。
通配选择器的 box-sizing: border-box 让声明的宽高包含边框和内边距,组件尺寸更容易推算。body 去掉浏览器默认外边距,表单控件使用 font: inherit,避免按钮和输入框突然换成系统默认字体。::selection 只改变用户选中文字时的配色,不影响正文自身的对比度。
这些规则属于全站基线,输入是浏览器默认样式,输出是一套更一致的初始状态。不要在这里用 outline: none 全局删除焦点轮廓,也不要为了消除某一个组件的间距而重置所有元素;全局规则影响每一章后续创建的页面。
.hero-grid 先设置纸张底色,再叠加两条互相垂直的线性渐变,形成 34 像素一格的方格纸。多张背景从上到下叠放,两个 linear-gradient 分别画横线和竖线。这里使用低透明度墨色,让纹理可见但不抢正文。
两种按钮共享圆角、内边距、字号和过渡,只在背景、边框与阴影上区分主次。display: inline-flex 很关键:它既让链接保持随内容伸缩的行内尺寸,也让 align-items 与 justify-content 真正拥有 flex 容器可对齐的子项。若只写后两个属性而不声明 flex 或 grid,它们不会产生居中效果。
hover 只改变位移、阴影和背景,因此功能不依赖动画。不要给导航链接增加只在 hover 才出现的关键文字,触屏和键盘用户未必触发相同状态。
.label、.field 与 .field-error 先定义后面表单会复用的标签、控件和错误文本。输入框聚焦时同时改变边框与外圈阴影,用户无需只靠颜色判断焦点。.cover 叠加径向与重复线性渐变,.cover::after 再画内边框;伪元素设置 pointer-events: none,不会挡住封面里的链接或按钮。
.cover-coral 等类由普通 CSS 明确声明,因此后面写 cover-${book.accent} 时仍然有现成规则。若改用 Tailwind 类,就不能只在运行时拼出 bg-${color},因为扫描器看不到最终完整类名。.prose-copy p + p 只给“前面紧邻另一个段落”的段落增加上边距,第一段不会被多推一层。
prefers-reduced-motion: reduce 由操作系统偏好触发。代码把动画和过渡压缩到接近瞬时,同时把循环次数限制为一次;页面功能、内容和焦点顺序都保持不变。!important 用于确保这条用户偏好能够覆盖组件中更具体的动画规则,不应成为普通样式的默认写法。
这里既有本页马上会用到的 Hero 网格与按钮,也预先放好了表单、封面和长文样式。它们仍然只是视觉原语,没有引入图书数据或交互逻辑。后续组件可以复用同一套语言。
保存后刷新任意页面。即使 Page 还没有重新排版,Tailwind 也已经能够识别 bg-paper、text-ink、bg-coral 等项目类名。
可以做一个短暂验证:把上一节关于页最外层的 main 临时加上 bg-paper text-ink,页面会出现暖白背景与墨色文字。确认后保留这两个类也没有问题;下一部分的根布局会把它们提升为全站默认值。
设计令牌的价值不在于给颜色换名字,而在于把“视觉角色”与“具体数值”分开。组件只关心这里是纸张背景还是强调色,整套主题则由集中的变量控制。
根 layout.tsx 包住 app 目录里的所有 Page,而且必须输出 html 与 body。当用户从首页进入书架时,Next.js 可以保留共享布局,只替换 children 对应的页面内容。
因此,全站都需要的导航、页脚、字体变量和基础 Metadata 应该放在根布局。它们不需要读取点击状态,也不需要浏览器 API,可以继续作为服务器组件,不必添加 "use client"。
我们会把页头和页脚分别拆成组件。拆分不是为了让文件数量变多,而是让根布局保持清楚:它只负责组装全站外壳。
新建 src/components/site-header.tsx:
import Link from "next/link";
const links = [
{ href: "/books", label: "书架" },
{ href: "/about", label: "关于" },
] as const;
export function SiteHeader() {
return (
<header className="sticky top-0 z-40 border-b border-ink/10 bg-paper/90 backdrop-blur">
<div className="mx-auto flex h-18 max-w-6xl items-center justify-between px-5 lg:px-8"
links 放在组件外,表示这是一份不会随渲染改变的导航配置。as const 收窄它的字符串类型,map 每次接收一条 { href, label } 并输出一个 li;稳定的 href 同时用作 React key 和 Link 目标。数据从配置数组流入列表,而不是复制两份几乎相同的 JSX。
页头的 sticky top-0 让它滚动到视口顶部后保持可见,z-40 保证内容卡片不会盖住它,半透明背景与 backdrop-blur 则让下面滚过的内容仍有轻微层次。品牌链接上的 group 把 hover 状态提供给内部图标,group-hover:rotate-0 只调整装饰角度,不改变链接目标。
nav 通过 aria-label="主导航" 获得可识别名称,品牌链接则用 aria-label 明确它会返回首页。页头目前只链接上一节已经存在的 /books 和 /about;后面创建新的一级页面时,再把入口加入 links,避免导航提前指向尚不存在的地址。不要把整个 nav 做成一个可点击区域,也不要在 Link 内嵌第二个按钮,这会制造嵌套交互控件。
新建 src/components/site-footer.tsx:
export function SiteFooter() {
return (
<footer className="mt-auto border-t border-ink/10 bg-white/55">
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-5 py-8 text-sm text-ink/55 sm:flex-row sm:items-center sm:justify-between lg:px-8">
<p>拾光书架 · 一个用 Next.js 16 完成的共读项目</p>
<p>把一本书读慢一点,把一句话记久一点。</p>
</div>
</footer>
);
}Footer 没有 props、状态或浏览器 API,输入是固定文案,输出是一个语义页脚。窄屏默认使用 flex-col 纵向排列两句文字,sm:flex-row 在空间足够时改成横向,并由 justify-between 把它们放在两端。
mt-auto 只有在祖先是纵向 flex 容器时才会把页脚推到底部;根布局下一步会提供这个条件。header、nav 与 footer 都是 HTML 的语义元素。它们不仅决定视觉结构,也帮助屏幕阅读器理解页面区域。导航中的每一个目标都会改变地址,所以使用 Link,而不是用按钮模拟跳转。
把 src/app/layout.tsx 替换为:
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono =
四组导入分别承担类型、字体、组件和样式职责。next/font 会在构建期间准备字体并让项目自托管,浏览器打开页面时不必再向 Google 字体服务请求文件。variable 选项返回一个包含 CSS 变量声明的类,模板字符串把两个变量类挂到 html,前面 globals.css 的字体栈才能读到它们。
请求进入任意 Page 后,根布局按固定顺序输出 Header、唯一的 main、Page 对应的 children 和 Footer。min-h-screen flex-col 建立至少一屏高的纵向容器,flex-1 让主体吸收剩余空间,所以短页面的页脚也会落到视口底部。这个布局没有状态和事件,仍是 Server Component;导入 Header 和 Footer 不会自动把它变成客户端组件。
Metadata 的 default 是站点兜底标题,template 只包裹子页面提供的标题。常见错误包括把字体变量类挂到不会包住所有内容的节点、删除根级 html/body,以及让 Page 保留第二个 main。下一步的标签调整正是为了消除最后一种错误。
根布局现在统一提供 main,因此上一节各 Page 不应继续保留自己的 main。打开下面五个文件,把最外层开始标签从 main 改成 section,结尾标签同步从 /main 改成 /section;原有类名和内部内容都不变:
src/app/about/page.tsx
src/app/books/page.tsx
src/app/books/[slug]/page.tsx
src/app/books/loading.tsx
src/app/not-found.tsx这次调整明确了结构所有权:根布局负责全站唯一的主要内容区域,各 Page 只负责这个区域中的具体章节。不要只改开始标签而漏掉结尾标签,否则 JSX 会报告标签不匹配。
依次打开 /、/books 与 /about。三个地址都应出现同一页头与页脚,页面主体则各不相同。内容较短时,min-h-screen、flex-col 与主体上的 flex-1 会把页脚推到视口底部,而不是让它紧贴正文。
字体配置只请求 Geist 的拉丁字形;中文会按照 --font-sans 中的字体栈回退到系统中文字体。浏览器标签仍显示“拾光书架”,关于页若保留自己的 metadata.title,则会套用模板显示“关于|拾光书架”。
首页需要明确的阅读顺序:先用一句主张告诉用户这是什么,再提供主要入口,接着说明项目将解决哪些问题,最后用一段短句收住情绪。这是信息架构,与图书究竟来自 JSON、数据库还是接口无关。
因此,这一版首页只使用静态文案和装饰性的 CSS 书封。它不导入 Book 类型,不读取图书或书摘,也不依赖收藏按钮。后面建立数据层时,我们会在稳定骨架中加入真实列表。
React 组件必须返回一个根节点,但这个根节点不一定要额外生成 HTML。Fragment 写作 <>...</>,可以并列放置多个 section,又不会制造没有语义的包裹层。
把 src/app/page.tsx 替换为:
import Link from "next/link";
const learningPath = [
{
label: "页面地图",
title: "先让每个地址各就各位",
description: "用 App Router 把首页、书架、关于页与动态详情组织成清楚的目录。",
color: "bg-blue",
},
{
label: "内容来源",
title: "再让真实数据进入页面",
description: "后续会建立类型与读取函数,让书籍内容只有一个可信来源。",
color: "bg-sage",
},
learningPath 是页面自己的静态展示内容,不是图书业务数据。它的作用是避免复制三份相同卡片结构,也顺便展示 React 如何使用 map() 从数组生成元素。每个元素都有稳定的 key,React 才能在更新列表时识别对应关系。
页面组件没有 props,也不读取服务器数据或浏览器状态。模块加载时先得到 learningPath,执行 HomePage 时再返回三个并列的 section;最外层 Fragment <>...</> 只满足 JSX 的单一返回值要求,不会生成无语义的包裹节点。此时的数据流完全是“模块常量与静态文案 → JSX → HTML”。
第一段 Hero 默认是一列,lg:grid-cols-[1.05fr_.95fr] 在宽屏把文字和插画分成比例稍有不同的两列。主要操作都使用 Link,因为它们会改变地址。右侧插画以 relative 容器作为绝对定位参照,两张封面再分别旋转、错位;最外层 overflow-hidden 防止装饰在窄屏制造横向滚动。整块标记 aria-hidden="true",因为相同书名只是装饰,正文没有依赖它传达新信息。
第二段把 learningPath 映射成三篇 article。item.color 保存的是 bg-blue 这类完整类名,所以 Tailwind 能在源码扫描阶段识别它;不要改成 `bg-${item.color}`。第三段使用 blockquote 表达引用,内部 footer 标明来源。标题层级从页面唯一的 h1 进入区域 h2,再进入卡片 h3,视觉大小与文档层次保持一致。
常见错误包括漏写 map 的 key、把导航 Link 换成没有地址语义的按钮、为了左右布局而交换 DOM 阅读顺序,或删除 Hero 的尺寸约束导致插画溢出。响应式样式应该改变排列方式,不应改变用户读到内容的先后关系。
打开 http://localhost:3000。页面顶部应出现暖色网格背景、主标题、两个可以导航的入口,以及两张错位书封和一枚“慢慢读”圆章。继续向下,应看到三张学习路径卡片和一段浅绿色的引语区域。
下面是 http://localhost:3000/ 在本章完成后的真实全页结果。截图以 1440×900 视口触发桌面断点并向下捕获完整页面;请依次观察固定页头、两列 Hero、三列学习路径、引语区和落在页面末尾的 Footer。它显示的是纯静态骨架,还没有第 4 课才会出现的真实书卡与收藏按钮。

点击“逛逛书架”会进入上一节的 /books 占位页;点击“了解这个项目”会进入 /about。首页此时不依赖任何尚未创建的模块,复制到当前进度的项目中可以直接成立。
这一阶段的完成标准很具体:首页有完整首屏与内容区,所有导航目标已经存在,共享页头和页脚覆盖三个静态入口,同时没有提前依赖图书数据或收藏交互。
Tailwind 默认采用移动优先。没有前缀的类先作用于所有宽度,sm:、md: 和 lg: 再在空间足够时增强布局。
首页 Hero 默认使用普通网格,所以窄屏是一列;lg:grid-cols-[1.05fr_.95fr] 到宽屏才变成左右两列。路径卡片默认上下排列,md:grid-cols-3 在中等宽度后改为三列。这里不是为某个具体设备写页面,而是让内容在可用空间变化时自然重排。
书封插画使用相对容器配合绝对定位。容器有 max-w-[500px],因此不会在大屏无限拉宽;书封同时设置窄屏与 sm: 尺寸,避免压住主标题。
保持首页打开,按下面顺序改变浏览器宽度:
先缩窄页面。主标题与按钮应先出现,书封插画排在它们之后;三张路径卡片改成单列,正文不应产生横向滚动条。
再逐步加宽。到中等宽度时,三张路径卡片排成一行;到较宽尺寸时,Hero 的文字与插画分列显示。
在每种宽度下点击书架、关于和首页标志,确认改变的只是内容布局,路由目标与共享外壳始终可用。
窄屏下,阅读顺序应是项目说明、主要操作、装饰插画、学习路径和引语;宽屏下,首屏利用横向空间形成左右平衡,但 DOM 中的阅读顺序没有改变。
如果窄屏出现横向滚动,先检查绝对定位的书封是否仍被 max-w 容器约束,再检查是否误删了最外层 Hero 的 overflow-hidden。不要先用全站 overflow-x: hidden 掩盖问题,那会让真正越界的内容更难发现。
能够看见页面,不等于每个人都能顺利使用它。屏幕阅读器依赖 header、nav、main、section、标题和链接理解结构;键盘用户依赖原生可聚焦元素完成导航;选择减少动态效果的用户则需要页面尊重系统偏好。
根布局已经提供唯一的 main,所以首页只返回若干 section,不再嵌套第二个 main。页面有一个描述主题的 h1,后续区域使用 h2,卡片标题使用 h3。装饰书封不会补充新信息,因此用 aria-hidden="true" 避免辅助技术逐字读出它。
Link 天然支持 Tab 聚焦与 Enter 激活。反过来,如果用带 onClick 的 div 代替链接,就需要自己补齐焦点、键盘事件、角色与状态,很容易遗漏。
从地址栏开始按 Tab 键。焦点应依次到达首页标志、书架、关于、逛逛书架和了解项目。按 Enter 后,每个链接都应进入对应地址。
再检查文档结构:页面中应有一个 header、一个有名称的 nav、一个 main 和一个页面级 footer。首页标题层级按照 h1、h2、h3 递进,没有为了字体大小跳级。
最后,在系统设置中开启“减少动态效果”再查看按钮。prefers-reduced-motion 媒体查询会把过渡时间压缩到接近即时,页面内容与链接仍然保持完整。
不使用鼠标也能抵达全部站内入口;装饰图形不会制造冗长朗读;共享页头、主内容与页脚有明确边界;减少动画后,页面不会依赖位移来传达信息。这些结果说明页面不仅“画完了”,也建立了可继续扩展的结构。
不要为了视觉效果移除浏览器的焦点轮廓,除非同时提供同样清楚的替代样式。键盘用户需要知道当前焦点位于哪个链接或控件。
此时项目已经有目录路由、全站设计令牌、共享 Header 与 Footer,以及完整的静态首页。还没有建立图书类型、数据读取函数、真实图书列表或收藏状态;这些能力会在现有骨架上逐层加入。
练习:如果想把全站纸张背景调得更浅,应该逐个修改所有 bg-paper 吗?