指南与教程 - 背景图片 | Rainy Design Studio

Next.js + Strapi + Tailwind CSS 建站教程 - 第四部分

RainyMay 26, 2026

本文章的版权归属 Rainy Design Studio - 雨点设计工作室 ,且最终解释权归站长所有。
除本站自媒体官号以外,本文的内容禁止任一组织、个人、账号或平台,以任何形式转载到 RainyDesign.cn 站外。
任何个人或组织进行非法转载的,本站工作人员可对其追究法律责任。

具体信息详见 条例与条款

Next.JS + Strapi + Tailwind CSS 全套网建实战教学 —— Tailwind CSS

使用 Tailwind CSS v4 定制你的网站布局,通晓全局样式配置、分层设计、原子类名与移动端优先的交互设计理念。

章节回顾

上一个章节我们搭建好了 Next.JS 项目,现在是时候用 Tailwind CSS 完善前端布局了。课程的完整目录如下:

  1. 准备工作

    1. 准备工作:基本需求
    2. 准备工作:项目流程
    3. 准备工作:通读设计稿 + 数据与接口设计
    4. 准备工作:MySQL简介
  2. Strapi

    1. Strapi:创建无头数据管理系统
    2. Strapi:管理数据库表结构
    3. Strapi:管理多媒体资源
    4. Strapi:填写内容、配置 API 权限并测试
    5. Strapi:REST API 讲解,实现增删改查及筛排分页
    6. Strapi:章节知识点汇总
  3. Next.JS

    1. Next.JS:创建项目并管理目录
    2. Next.JS:管理路由,搭建基础布局与页面
    3. Next.JS:API 函数与页面渲染(SSR、SSG、CSR)
    4. Next.JS:内建组件与资源引用
    5. Next.JS + Strapi:开发与数据传递
    6. Next.JS:章节知识点汇总
  4. Tailwind CSS

    📌 当前位置
    1. Tailwind CSS:简介、安装、新特性与旧版本升级
    2. Tailwind CSS:示例、核心概念与演练
    3. Tailwind CSS:全局组件样式开发
    4. Tailwind CSS:主要页面布局实战
    5. Tailwind CSS:章节知识点汇总
  5. 细节完善

    1. React.JS (JSX) :使用 ReactMarkdown 插件美化正文
    2. Strapi + Next.JS + JSX:博客文章搜索
    3. Strapi + Next.JS + JSX:文章列表分页
    4. Next.JS:为 SSG 页面设置 ISR 以实现性能优化与预生成
    5. Strapi Lifecycles hooks:客户联络自动转发邮件
    6. Next.JS:自定义错误页面如 404、500
    7. Next.JS:创建 sitemap.xml 文件并自更新
  6. 网站部署 (撰写中)

    1. 网站部署:Cloudflare、Vercel或租赁云服务器(仍在撰写)

    2. 网站部署:注册域名 + SSL证书申请(仍在撰写)

    3. SEO:搜索引擎提交收录(仍在撰写)

  7. 资源与教程项目包下载

    点击此处下载,最后更新于:2026 年 5 月 26 日。为获取良好的学习体验,请务必以最新版本为主!
  8. FAQ(常见疑问解答)

四、Tailwind CSS

💕 我最喜欢 CSS 了,所以本章节的内容会带有大量的个人色彩

嗨宝子们,欢迎回来!

搭建完了网站也成功获取了数据,现在我们终于要开始对网站装修一番,也就是构建布局并应用样式了。

如果先前关闭了电脑,我们需要重新按照顺序启动一下项目:

# 打开第一个终端 brew services start mysql # 或 mysql.server start, # Windows OS 为 net start mysql,当然如果你之前是用 brew services 启动的 mysql 那么它可能已经自动启动了 cd ~/Documents/my-project/strapi/ # Windows OS 为 D: 回车后 CD D:\my-project\strapi yarn dev # 或 npm run dev # 打开第二个终端 cd ~/Documents/my-project/nextjs # Windows OS 为 D: 回车后 CD D:\my-project\nextjs yarn dev # 如启动项目时提示 Node.js 版本兼容问题 node -v # 查看 Node.js 版本号 nvm use 25.1 # 切换 Node.js 环境为 v25.1,然后重新执行上述命令行

现在我们已经能通过本地端口号访问 StrapiNext.JS 项目了,让我们正式开始吧。

4.1 Tailwind CSS:简介、安装、新特性与旧版本升级

4.1.1 框架简介

Tailwind CSS 是一个原子类优先(utility-first)的 CSS 框架,它提供了大量的原子级 CSS 类,让开发者可以直接在 HTML 中快速构建现代化的用户界面。与传统的 CSS 框架(如 Bootstrap)不同,Tailwind CSS 不提供预设计的组件,而是提供了构建组件所需的底层工具类。

这种设计理念的核心在于"组合优于预设"。开发者可以通过组合多个小的、单一原子类的 CSS 类来创建复杂的设计,而不是依赖于预定义的组件样式。比如创建一个蓝色的按钮,使用 Tailwind CSS 时我们可以直接应用 bg-blue-500 text-white px-4 py-2 rounded 这样的类组合,而不是一个单独的 .btn-primary 类,还需要在 CSS 文件中规定它的样式。

Tailwind我们来一起看一个由纯粹的 Tailwind CSS + HTML 搭建的水晶球案例:

应用了 Tailwind CSS 的 HTML


<!-- Rainy Design Demo: Crystal Ball --> <span class="relative block w-40 h-40 mb-12"> <!-- shadow --> <span class="absolute z-0 -right-6 -bottom-8 block h-28 w-32 rotate-35 rounded-full bg-black/33 blur-md before:absolute before:right-8 before:bottom-6 before:h-18 before:w-24 before:rounded-full before:bg-[#F0F0F0]/90"></span> <!-- body --> <span class="absolute z-10 block size-full rounded-full bg-linear-to-br from-[#AAAAAA]/70 from-20% via-[#DADADA]/80 via-60% to-[#F0F0F0]/90 to-80% shadow-[inset_0_0_1rem_rgba(255,255,255,.8)]"></span> <!-- highlight --> <span class="absolute z-20 top-6 left-6 block size-16 before:absolute before:size-full before:-rotate-30 before:rounded-full before:border-t-16 before:border-white before:blur-md after:absolute after:top-2.5 after:left-4 after:size-2 after:rounded-full after:bg-white after:blur-xs"></span> </span>

渲染结果


以下是 Tailwind CSS 对比传统 CSS 的一些特性:

  • 专注功能,而非语义化:

    • Pro:无需苦思冥想类名命名,而是直白地使用底层工具类;
    • Con:不方便识别每个标签的具体用途或语义;
      • 解决方法:1. 为元素添加 ID 或 data 属性;2. 添加注释;3. 使用组件化开发模式封装语义。
  • 弱化结构,强调独立性:

    • Pro:直接通过类名控制标签样式与布局属性,无需频繁改动 CSS 代码,在 HTML 与 CSS 之间来回切换;
    • Con:单个元素的类名组合容易写得很长,元素较多时阅读困难;
      • 解决方法:1. 在 VSCode 中安装 Tailwind Fold 插件一键折叠类名;2. 适当整合部分常见类名组合,如将圆角蓝底按钮提取为自定义类;3. 使用组件化框架(如 React、Next.JS)封装复用组件。
  • 极致优化,样式轻量化:

    • Pro:CLI 自带监听与优化参数,无需分布式管理,实时检测并输出一个全局 CSS 文件,打包所有样式;
    • Con:依赖对 Tailwind CSS 各项参数的深入理解,且需避免代码冲突;
      • 解决办法:确保输入源只有唯一入口,或为多个输入源定义 @source(none),以及使用 prefix() 定义前缀名。
  • 支持变量,高度自定义

    • Pro:除预设值外可直接使用任意值语法 left-[1rem] ,会直接生成对应的 CSS 代码,且支持全局自定义配置、按需调用;
    • Con:检测不到渲染生命周期结束后新增的类名(如动态页面数据包含的类名),且需要一定学习与提示成本;
      • 解决方法:1. 使用 safelist 确保动态类名必定保留;2. 在 VSCode 中使用 Tailwind CSS IntelliSense 进行智能提示;3. 配置 content 路径确保扫描到所有可能的类名。
  • 兼容原生,响应式设计:

    • Pro:完全兼容原生 CSS ,且采用移动优先(mobile-first)的响应式设计理念,从 min-width 逐步扩张到桌面端,更符合当下移动互联网时代的网站与 H5 应用开发、SEO 与性能考量逻辑;
    • Con:需要写一堆响应式前缀,断点适配较多时类名会很长,不方便检查冲突;
      • 解决方法:1. 在 VSCode 中使用 Tailwind CSS IntelliSense 检测代码冲突与断点协助;2. 合理规划断点策略,避免过度细分;3. 使用容器查询(@container)等现代 CSS 特性减少断点依赖。

4.1.2 安装与配置

Tailwind CSS 提供了多种安装方式,适应不同的项目需求和开发环境。

本教程仅列出适用于本项目开发与案例讲解的三种安装方式,请查阅 Tailwind CSS 官方文档 - 安装 以了解更多信息。

方式一:通过 PostCSS 插件

这一步已经在本教程第三章: 3.4.2 Next.JS:CSS 引用 中具体讲解过了,按照教程一步步做过来的可以检查一下,无需重复操作。

手动建立的 Next.JS 会自带 PostCSS,我们可以将 Tailwind 作为插件添加:

# 停止 Next.JS 项目后执行 yarn add -D tailwindcss @tailwindcss/postcss # 或使用 npm 管理 npm install -D tailwindcss @tailwindcss/postcss

然后在 postcss.config.js 中配置:

export default { plugins: { '@tailwindcss/postcss': {}, }, }

再为先前引入的 @/styles/globals.css 添加内容:

@import "tailwindcss";

最后打开 package.json ,可以看到 devDependencies 对象中已添加了 @tailwindcss/postcss 依赖,至此 Tailwind CSS 插件已安装完成。

小提示:对比上个版本,Tailwind CSS v4 采用了新的架构,默认已经不再需要 tailwind.config 配置文件了,而是通过直接在引入 CSS 中添加 @config 对象如 theme.extend.colors.primary 来自定义配置。

方式二:框架集成

在执行 Next.JS 项目创建命令时添加 with-tailwindcss,如 npx create-next-app -e with-tailwindcss my-project ;或在安装询问步骤中对预置 Tailwind CSS 的选项默认确认,无需修改即可创建包含了 Tailwind CSS 插件与配置文件的 Next.JS 项目。


方式三:独立版本 这是最小化依赖开发者的福音、代码洁癖患者的救赎、Vanilla 忠诚派的首选,更是脱离 Node.js 环境依赖、直接启动本地 Tailwind CSS 的终极杀招。

下载链接:Tailwind Labs / Tailwind CSS Standalone Version Releases on Github

如果你心想:”其他课程我不看,我就是冲着 Tailwind CSS 来的“、”我用 Angular / React / Vue 或者别的什么语言,对 Next.JS 不感兴趣“、“万般皆下品,唯有原生高”,很 OK,因为早五六年前我也是这么想的,我不但理解还非常支持,尤其是后者(详见 额外章节:我与 CSS 的不解之缘 (在写了在写了) )。

你有这么高速运转的独立版本进入项目,直接几句命令行完事儿了:

# 给完权限后 mkdir -p ./styles touch ./styles/input.css ./styles/output.css # 在已有 styles/ 目录中创建 input.css 与 output.css 文件 ./tailwindcss -i ./styles/input.css -o ./styles/output.css --watch # 以监听模式启动,输入源为 input.css,输出为 output.css vim ./styles/input.css # 使用 Vim 编辑器编辑 input.css

然后在 Vim 编辑器中粘贴 @import "tailwindcss"; ,接着按 Esc 键 ,输入 :wq 回车保存,当然用现代化的 VSCode 编辑也没问题;对 Vim 感兴趣的宝子可自行查阅 Vim 操作指南 参考学习。

接下来 touch 个 index.html 之类的,在 <head> 里头常规引入 output.css 开始爽玩。

此外,我们推荐在 VSCode 中安装 Tailwind CSS IntelliSense 插件,以实现包含智能提示 utilities、悬停显示单位在内的各项功能:

Tailwind CSS - VSCode 插件 Tailwind CSS IntelliSense - 安装页面 | Rainy Design Studio 雨点设计工作室

Tailwind CSS - VSCode 插件 Tailwind CSS IntelliSense - 应用场景 | Rainy Design Studio 雨点设计工作室

4.1.3 Tailwind CSS v4 兼容性、新特性与自 v3 升级

在讲解前我们需要先强调一点:考虑到兼容性,采用 Tailwind CSS 的最新版本是非必要的,因为其支持的浏览器较新,我们需要格外注意项目的使用场景,尤其是需要支持较旧的终端(及考虑更早的设备、系统及浏览器)、或是现有项目仅需进行保守性优化而非重构的,我们更推荐使用 Tailwind CSS v3(官方最后版本截止 2025 年 9 月为 v3.4.17)。
追加说明:v4 对比 v3 在部分类名的语法上也有破坏性的改动,如 flex-grow 已变更为 grow,因此从 v3 升级的项目需格外注意类名的调整与维护。

以下是截止截稿日前,Tailwind CSS v4 兼容的浏览器版本号:

  • Chrome 111 (released March 2023)
  • Safari 16.4 (released March 2023)
  • Firefox 128 (released July 2024)
v4 对比 v3 最关键的特性与改动
  • 破坏性的类名变更(如 flex-grow 变更为 grow
  • 重写了引擎与架构,全量与增量构建速度显著提升
  • 告别了 tailwind.config.js ,采用 CSS 优先的配置方式如 @theme 直接进行主题定义
  • 动态工具值支持直接输入合适的值以避免依赖预定义,如 w-103grid-cols-15rotate-107 等等
  • 现代 CSS 特性支持如 @layer@propertycolor-mix@starting-style 等等
  • 专属 Vite 插件与独立 CLI 包(@tailwindcss/vite @tailwindcss/cli),具体请于官方自查

需注意,Sass、Less 和 Stylus 并不在 Tailwind CSS 的兼容性考虑之列,不过得益于这些先驱为前端编码披荆斩棘地开拓道路,我们能在后续的 Tailwind CSS 语法中找到很多前者的影子。

构建时导入、本地变量与嵌套 CSS

Tailwind CSS 支持使用 @import 语法导入 CSS 模块。一个最典型的例子如下:

@import "tailwindcss"; @import "./typography.css";

在这个例子中,同目录下的 typography.css 会自动捆绑到你的 Tailwind CSS 中去而无需使用第三方引入工具。其内容示例如下:

.typography { font-size: var(--text-base); color: var(--color-gray-700); }

本地变量(Native CSS Variable)在这里工作得很好,我们只要进行事先自定义然后引入即可。

此外你可能已经注意到了,Tailwind CSS 正是一个高度依赖本地变量的 CSS 框架,所以,既然你都在用 Tailwind CSS 了,多用点本地变量当然没问题。

最后是一个嵌套 CSS (Nesting CSS)示例:

typography.css


.typography { p { font-size: var(--text-base); } img { border-radius: var(--radius-lg); } }

output.css


.typography p { font-size: var(--text-base); } .typography img { border-radius: var(--radius-lg); }

可以看到 Tailwind CSS 帮我们自动铺平了嵌套的 CSS 样式,不过这些年 CSS 的嵌套支持已经相当成熟了,即便你不采用 Tailwind CSS 也无需使用预处理器来处理嵌套 CSS,除非该项目的工作场景是旧版终端。

但就像先前提到的那样,特定需求建议继续使用兼容性较好的 v3 版本。如果你正在构建一个崭新的项目并匹配当下最新的使用场景,那 v4 将会是你的最佳选择。

自 v3 升级

自动升级使用一行代码即可完成:

npx @tailwindcss/upgrade

Next.JS 项目默认使用 PostCSS,所以可在升级之后对 postcss.config.mjs 文件作出调整:

export default { plugins: { // 删除以下备注行 // "postcss-import": {}, // tailwindcss: {}, // autoprefixer: {}, // 添加下行 "@tailwindcss/postcss": {}, }, };

这样一来我们就将 Next.JS 中的 Tailwind CSS v3 顺利升级到 v4 了。

4.2 Tailwind CSS:示例、核心概念与演练

学习这个小节之前,我们默认屏幕前的宝子们对原生 CSS 的掌握程度为 精通 ,因此本章节会直接上示例,不会围绕原生 CSS 展开讲解。

4.2.1 原子类名示例

我们先来看一个非常典型的 Subscription 布局原型,其中有包含了一个 <h4> 和一个 <p><div> ,以及一个包含了两个 <input><form> ,让我们按步骤来,简单明快地讲解一个示例:

Tailwind CSS - 参考示例图片 | Rainy Design Studio 雨点设计工作室

第一步:构建 HTML 盒模型

代码参考


<div> <div> <h4>Subscription</h4> <p>Unlock deeper insights to master frontend technologies and web design expertise.</p> </div> <form> <input type="text" name="subscription" placeholder="Your E-mail or WhatsApp"/> <input type="submit" name="submit" placeholder="submit" /> </form> </div>

渲染结果


Subscription

Unlock deeper insights to master frontend technologies and web design expertise.

受 Tailwind CSS 的 Prelight 机制影响,大部分的格式都被清除了。


第二步:变更布局

代码参考


<div class="px-6 py-16 text-neutral-600 bg-slate-200"> <div class="space-y-4"> <h4 class="text-2xl leading-8 font-medium">Subscription</h4> <p class="mt-4">Unlock deeper insights to master frontend technologies and web design expertise.</p> </div> <form class="flex flex-col space-y-6 mt-9"> <input type="text" name="subscription" placeholder="Your E-mail or WhatsApp" class="border h-15 text-center rounded-full border-neutral-300 placeholder-neutral-300"/> <input type="submit" name="submit" placeholder="submit" class="h-15 text-center rounded-full text-white uppercase bg-neutral-300 cursor-pointer"/> </form> </div>

渲染结果


Subscription

Unlock deeper insights to master frontend technologies and web design expertise.

第三步:实现响应式布局

代码参考


<div class="px-6 py-16 text-neutral-600 bg-white md:flex md:items-center md:p-12"> <div class="space-y-4 md:max-w-xl md:mr-auto"> <h4 class="text-2xl leading-8 font-medium">Subscription</h4> <p class="mt-4">Unlock deeper insights to master frontend technologies and web design expertise.</p> </div> <form class="flex flex-col space-y-6 mt-9 md:flex-row md:mt-0 md:ml-9 md:space-y-0 md:space-x-4"> <input type="text" name="subscription" placeholder="Your E-mail or WhatsApp" class="shrink-0 border h-15 text-center rounded-full border-neutral-300 placeholder-neutral-300 md:min-w-78"/> <input type="submit" name="submit" placeholder="submit" class="shrink-0 h-15 text-center rounded-full text-white uppercase bg-neutral-300 cursor-pointer md:min-w-40"/> </form> </div>

渲染结果


Subscription

Unlock deeper insights to master frontend technologies and web design expertise.

这样一来,我们就完成了这个案例的 Tailwind CSS 代码。

此外,官方推荐用户于 VSCode 插件广场中下载并安装 Tailwind CSS Intellisense 插件,还有好看实用的 Prettier 可供使用(不过本人已经有一套类名的书写逻辑就用不上它了);本人这边额外推荐一个 Tailwind CSS Fold ,可通过按下 ⌘ / CTRL + ⌥ / ALT + A 实现一键折叠 Tailwind CSS。

4.2.2 核心理念

现在让我们一起探索 Tailwind CSS 的核心理念,并快速了解其对比其他 CSS 框架决定性的优势。此处直接引用 Tailwind CSS 官方介绍一言以概:

Rapidly build modern websites without ever leaving your HTML.
—— Tailwind CSS Official Website

是的,诚如 Tailwind CSS v4 的 motto 所述,除了后续讲到的封装一些组件级别的类名以外,我们几乎完全停留在 HTML 编辑窗口上,而不用再去编辑繁琐的 CSS 了。

由于本小节一共包含 9 个子栏目,文本量洋洋洒洒上千行,此处全部讲解需要花费大量的时间,所以我们将其整理出来、单独作为一个 Tailwind CSS 开发指南 以供查阅,并在后续的各个场景按需穿插引用。
使用原子类定义样式(Styling with utility classes)

首先,如果你跟我一样喜欢 Standalone 生产环境,仅用一个 HTML 文件做测试,则可以于 my-project 目录下手动创建一个 static 项目。以下是终端操作流程:

Terminal

本人使用的是 Apple silicon 的 MacBook,这里请根据你的系统和架构自取版本,详见 这里

cd $HOME/Documents/my-project/ curl -L -o tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.13/tailwindcss-macos-arm64 # 具体链接视最新版本而定,下载不了的话可以在课程资源包里头复制一个 mkdir static mv tailwindcss static/tailwindcss cd static touch index.html mkdir img styles fonts touch styles/globals.css styles/theme.css chmod +x ./tailwindcss # 如果你是从课程资源包里复制过来,且运行在 MacOS 下的话,就需要输入这个 xattr -d com.apple.quarantine ./tailwindcss # 移除 macOS 中文件的隔离属性,如果权限不够就试试在最前面添加 sudo,回车后键入密码 vim styles/globals.css # 在 Vim 中编辑 CSS @import "tailwindcss"; # 编辑完毕后按 Esc 再输入 :wq 回车退出 vim index.html # 在 Vim 中编辑静态页面 <!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="stylesheet" href="styles/theme.css" /> </head> <body> </body> </html> # 编辑完毕后按 Esc 再输入 :wq 回车退出 ./tailwindcss -i styles/globals.css -o styles/theme.css --watch --minify # 启动 Tailwind CSS Standalone 程序,以 styles/globals.css 作为配置文件,同目录下的 theme.css 作为输出文件,实时监听并压缩文件

然后请把 课程资源包 目录下项目文件中的 static/img/static/fonts/ 复制到对应的目录。完成准备工作后,现在我们用 Live Server 打开 my-project 目录下的 static/index.html

没有安装 Live Server 的宝子们请直接在 VSCode 插件广场搜索并自行安装,然后右键点击网页,或是在编辑 HTML 时按快捷键 ⌘ L, ⌘ O 打开。

运行 Live Server 时,请确保其工作区位于 my-project/static

这样一来我们就有了一个稳定的测试环境:

my-project/static
static ├── fonts # 字体 │ └── ... ├── img # 图片 │ └── ... ├── styles # 样式 │ ├── globals.css # 输入文件 │ └── theme.css # 输出文件 ├── index.html # 用做测试的 html └── tailwindcss # Tailwind CSS Standalone 程序

现在我们可以在 <body> 中加入所需的代码进行本地测试。此处举一个简单的例子:

代码片段


<div class="demo"> <div class="relative w-43.5 h-69 ring-4 ring-neutral-100 shadow-lg rounded-3xl font-serif text-slate-700 transition duration-1000 ease starting:-translate-x-12 starting:opacity-0"> <img src="/temp/playground_social_post_bg.png" width="232" height="368" class="absolute object-cover size-full"> <span class="absolute block h-1/2 w-full bottom-0 rounded-3xl mask-t-from-75% backdrop-blur-xl"></span> <div class="flex relative flex-col justify-end h-full p-4 text-white"> <h4 class="text-sm font-bold">Heading here</h4> <p class="text-xs font-sans font-extralight tracking-wider">aenean sapien ea mollit et, gravida tempor ut eiusmod laboris quis.</p> <button class="w-full p-2 mt-2 font-sans text-xs font-medium text-center text-white uppercase transition duration-300 rounded-full bg-linear-to-r from-green-500/50 to-emerald-500 cursor-pointer hover:from-green-600/50 hover:to-emerald-600" disabled>View more</button> </div> </div> </div>

渲染结果


Rainy Design Studio

Heading here

aenean sapien ea mollit et, gravida tempor ut eiusmod laboris quis.

这里我们展开解释一下:

  • 使用 relativeabsolutefixed 等类名直接设置 position 属性;
  • 使用 blockinlineflexinline-flex 等类名直接设置 display 属性;
  • 使用 w-36h-48max-w-[2rem]left-1/2bottom-0 等类名直接设置尺寸与坐标属性;
  • 使用 p-4px-2mt-2rounded-3xlborder-neutral-400 等类名设置 paddingmarginborder 属性;
  • 使用 text-whitetext-boldtext-smfont-[Outfit]font-serif 等类名直接设置 textfont 相关属性;
  • 使用 bg-centerbg-no-repeatbg-[url('...')]bg-position-[50%_25%]bg-linear-to-bfrom...via...to... 等类名直接设置 background 相关属性;
  • 以及使用 hover:...before:...checked:...not-focus:...nth-[2n+1_of_li] 等类名使用伪类及状态选择器。

具体请直接查阅 Tailwind CSS v4 官方文档 —— Layout - aspect-ratio 了解所有类名,或是参阅本站的 Tailwind CSS 开发指南 - 类名速查 快速了解常用原子类名。

小提示:Tailwind CSS 中除了 border 等类名以外,大部分常用的单位尺寸是由全局参数 --spacing 决定的,其值为 0.25rem (4px),所以需注意像 w-96 的实际宽度为 24rem。该值取决于根字体大小,默认换算为 1rem = 16px 。
注意:Tailwind CSS 仅会扫描项目对应的文件,并将其视作纯粹的字符串作为输入源,完整匹配了 flex 这样的显式类名时,才会将对应的原子类名与样式添加到输出文件;反之,当输入源不包含显式类名,或输入源来自数据需要做动态渲染时,相关的类名便不会被输出:

数据源


{ "data": { "textColor": "sky-600", "body": "<p class=\"underline\">lorem ipsum</p>" } }

HTML


<p className="text-red-500"></p> // ✅ 会被识别,因为是完整声明 <p className={`text-${data.textColor}`}></p> // ❌ 不会被识别,因为非完整显式声明 <Markdown data={data.body} /> // ❌ 不会被识别,因为外部数据并非编译阶段的输入源

具体知识点详见 Tailwind CSS 开发指南 - 检测源文件中的类

4.2.3 Tailwind CSS:在线演练

现在介绍一下官方的在线演练场。请访问 https://play.tailwindcss.com 并打开 Tailwind CSS - 官方文档

Tailwind CSS - 在线演练场 Playground | Rainy Design Studio 雨点设计工作室

现在让我们来进行一些编辑范例。官方会不定期更新 Playground 示例代码,所以我们以上图为准,清空输入框并粘贴下列示例用的代码:

参考代码


<div class="demo p-12"> <!-- Rainy Design Demo: Product Card --> <div class="relative w-120 h-180 border border-orange-900 text-orange-900 transition duration-1000 starting:-translate-x-12 starting:opacity-0"> <!-- Header --> <div class="flex justify-between items-center w-full h-13 border-b border-orange-900"> <h2 class="font-bold indent-4 uppercase">Rainy Design</h2> <button class="relative flex flex-col justify-center items-center size-13 p-3.5 border-l border-orange-900 space-y-2 cursor-pointer"> <span class="relative w-full h-0.5 bg-orange-900 before:absolute before:w-0.5 before:h-1 before:left-0 before:-top-px before:bg-orange-900 after:absolute after:w-0.5 after:h-1 after:-top-px after:right-0 after:bg-orange-900"></span> <span class="relative w-full h-0.5 bg-orange-900 before:absolute before:w-0.5 before:h-1 before:left-0 before:-top-px before:bg-orange-900 after:absolute after:w-0.5 after:h-1 after:-top-px after:right-0 after:bg-orange-900"></span> <span class="relative w-full h-0.5 bg-orange-900 before:absolute before:w-0.5 before:h-1 before:left-0 before:-top-px before:bg-orange-900 after:absolute after:w-0.5 after:h-1 after:-top-px after:right-0 after:bg-orange-900"></span> </button> </div> <!-- Image container --> <div class="relative flex justify-between items-center"> <span class="absolute block w-93 h-95 left-6 top-6 border border-orange-900"></span> <img src="/temp/demo.png" alt="temp image | Rainy Design Studio" width="372" height="380" class="relative w-93 h-95 object-cover object-top"/> <div class="flex items-center font-serif w-20 font-extralight whitespace-nowrap space-x-3 rotate-90 animate-bounce cursor-pointer"> <p>up to next</p> <div class="shrink-0 flex justify-center items-end relative size-6 pb-1.25 rounded-full border border-orange-900"> <span class="block w-px h-1.5 rounded bg-orange-900 -rotate-45"></span> <span class="block w-px h-3 rounded mx-0.5 bg-orange-900"></span> <span class="block w-px h-1.5 rounded bg-orange-900 rotate-45"></span> </div> </div> </div> <!-- Informations --> <div class="relative w-93 mt-14 ml-6 space-y-4"> <div class="relative flex justify-between items-center"> <div class="relative"> <p class="text-xs font-serif font-extralight">Women's Clothing / Dresses</p> <h3 class="mt-1 text-xl font-bold">Black Swan</h3> <span class="block w-20 h-0.5 mt-3 bg-orange-900"></span> </div> <p class="text-3xl font-extrabold">89$</p> </div> <p class="text-sm font-serif font-extralight">cras in scelerisque est pretium sapien gravida est velit nec laoreet lacus in gravida enim eu, felis quis ut ut elit, fermentum. congue, sit id mauris nulla tempor.</p> </div> <!-- Size buttons --> <form for="size" class="relative flex w-56 h-8 mt-8 ml-6 space-x-4"> <div class="relative flex justify-center items-center size-8 border border-orange-900 transition duration-300 has-peer-checked:bg-orange-900 has-peer-disabled:opacity-50"> <input type="radio" name="size" class="peer appearance-none absolute size-full left-0 top-0 cursor-pointer"/> <label for="size" name="size" class="font-serif transition duration-300 peer-checked:text-white">XS</label> </div> <div class="relative flex justify-center items-center size-8 border border-orange-900 transition duration-300 has-peer-checked:bg-orange-900 has-peer-disabled:opacity-50"> <input type="radio" name="size" class="peer appearance-none absolute size-full left-0 top-0 cursor-not-allowed" disabled/> <label for="size" name="size" class="font-serif transition duration-300 peer-checked:text-white">S</label> </div> <div class="relative flex justify-center items-center size-8 border border-orange-900 transition duration-300 has-peer-checked:bg-orange-900 has-peer-disabled:opacity-50"> <input type="radio" name="size" class="peer appearance-none absolute size-full left-0 top-0 cursor-pointer" checked/> <label for="size" name="size" class="font-serif transition duration-300 peer-checked:text-white">M</label> </div> <div class="relative flex justify-center items-center size-8 border border-orange-900 transition duration-300 has-peer-checked:bg-orange-900 has-peer-disabled:opacity-50"> <input type="radio" name="size" class="peer appearance-none absolute size-full left-0 top-0 cursor-pointer"/> <label for="size" name="size" class="font-serif transition duration-300 peer-checked:text-white">L</label> </div> <div class="relative flex justify-center items-center size-8 border border-orange-900 transition duration-300 has-peer-checked:bg-orange-900 has-peer-disabled:opacity-50"> <input type="radio" name="size" class="peer appearance-none absolute size-full left-0 top-0 cursor-pointer"/> <label for="size" name="size" class="font-serif transition duration-300 peer-checked:text-white">XL</label> </div> </form> <!-- Add to cart --> <button class="absolute w-48 h-16 right-0 bottom-0 bg-orange-900 text-white font-serif text-lg opacity-50 transition duration-300 cursor-pointer hover:opacity-100">Add to Cart</button> </div> </div>

渲染预览


Tailwind CSS - 在线演练场 Playground 3 | Rainy Design Studio 雨点设计工作室

像这样,我们就可以自由的在这个演练场中编辑自己喜欢的内容了,左上角点 CSS 还可以写一些 Nested CSS (我们后续会讲到 Tailwind CSS 的 Component 与 Utilities 分层),左下角则是生成的 CSS 内容。配合 Tailwind CSS 官方文档 查询各个类名,我们可以在线细细感受 Tailwind CSS 的魅力。

通过按下 ⌘ / CTRL + S 组合键保存,我们还可以在线格式化 Tailwind CSS,使其变得准确且易读。

此外,CodePen 与 CodeSandbox 等等也是不错的选择,也有大量在线案例可以 fork,宝子们选择自己喜欢的就好。

4.3 Tailwind CSS:全局组件样式开发

现在,让我们回到项目开始开发组件,我们有两种开发方式,其一是单个组件直接开发完毕,其二则是为每个组件添加样式后再逐一为其添加断点样式,考虑到测试需要,我们选用前者。

4.3.1 配置与准备

打开 Next.JS 项目目录下的 @/styles/globals.css ,让我们按照顺序操作并逐一讲解。

第一步:指定文件源
@/styles/globals.css
@import "tailwindcss"; @source "../pages/**/*";

我们仅需引入 @/pages/ 目录下的文件,因为每个页面已包含了对应的组件,Tailwind CSS 会对其一并检测。如需了解更多相关知识点,请参阅 Tailwind CSS 开发指南 - 检测源文件中的类

第二步:定义主题
@/styles/globals.css
... @theme { /* Colors */ --color-primary: var(--color-slate-600); --color-secondary: var(--color-slate-500); /* Size */ --max-width-8xl: 96rem; }

@theme 块用于定义设计 tokens(CSS 自定义属性),这些 tokens 可用于 utilities 的生成,也可被自定义样式引用。这些内容可以进行全局引用,下文详述。

小提示:如需快速查阅并复制 Tailwind CSS Color 的各项属性,请访问 Tailwind v4 颜色选择器 - Rainy Design Studio | 雨点设计工作室 了解。
第三步:定义组件层

接着让我们定义一下组件层(@layer components),一个比较好的例子是裁切精灵图。

在上个一章节我们已经将 课程资源包 中的 项目文件/nextjs/public/ 复制到我们的 Next.JS 项目目录下,所以我们的 public/image/ 目录中会有 icons.svg 这一文件;根据 Tailwind CSS 的类名约定,图标应该定义于组件层 @layer components 之中,我们这里具体实现一下,直接粘贴以下代码到 globals.css 末尾即可:

@/styles/globals.css
@layer components { /* Icons */ .icon { display: block; background-image: url('/image/icons.svg'); background-repeat: no-repeat; } /* Icons of navigation */ /* List */ .icon[class*="icon-nav-list-"]{ width: 1rem; height: 1rem; } .icon[class*="-nav"][class*="-filled"]{ background-position-y: -1.75rem; } .icon[class*="icon-nav-list-home"]{ background-position-x: 0; } .icon[class*="icon-nav-list-course"]{ background-position-x: -1.75rem; } .icon[class*="icon-nav-list-blog"]{ background-position-x: -3.5rem; } .icon[class*="icon-nav-list-about"]{ background-position-x: -5.25rem; } .icon[class*="icon-nav-list-contact"]{ background-position-x: -7rem; } .icon[class*="icon-nav-list-toolbox"]{ background-position-x: -8.75rem; } .icon[class*="icon-nav-list-mail"]{ background-position-x: -10.5rem; } .icon[class*="icon-nav-list-order"]{ background-position-x: -12.25rem; } .icon[class*="icon-nav-list-care"]{ background-position-x: -14rem; } .icon[class*="icon-nav-list-service"]{ background-position-x: -15.75rem; } /* Contact */ .icon[class*="icon-nav-contact-"]{ width: 1.5rem; height: 1.5rem; background-position-y: -3.75rem; } .icon.icon-nav-contact-website{ background-position-x: 0; } .icon.icon-nav-contact-bilibili{ background-position-x: -2.25rem; } .icon.icon-nav-contact-wechat{ background-position-x: -4.5rem; } .icon.icon-nav-contact-email{ background-position-x: -6.75rem; } .icon.icon-nav-contact-more{ background-position-x: -9rem; } /* Icons of footer */ /* Contact */ .icon[class*="icon-footer-contact-"]{ width: 1.25rem; height: 1.25rem; background-position-y: -6.75rem; } .icon.icon-footer-contact-email{ background-position-x: 0; } .icon.icon-footer-contact-bilibili{ background-position-x: -2rem; } .icon.icon-footer-contact-wechat{ background-position-x: -4rem; } /* Social */ .icon[class*="icon-footer-social-"]{ width: 1.5rem; height: 1.5rem; background-position-y: -9rem; } .icon.icon-footer-social-bilibili{ background-position-x: 0; } .icon.icon-footer-social-wechat{ background-position-x: -2.25rem; } .icon.icon-footer-social-redbook{ background-position-x: -4.5rem; } .icon.icon-footer-social-facebook{ background-position-x: -6.75rem; } .icon.icon-footer-social-instagram{ background-position-x: -9rem; } .icon.icon-footer-social-tiktok{ background-position-x: -11.25rem; } .icon.icon-footer-social-youtube{ background-position-x: -13.5rem; } .icon.icon-footer-social-twitter{ background-position-x: -15.75rem; } /* Icons of frameworks */ .icon[class*="icon-framework"]{ width: 2.25rem; height: 2.25rem; background-position-y: -12rem; } .icon.icon-framework-strapi{ background-position-x: 0; } .icon.icon-framework-nextjs{ background-position-x: -3.25rem; } .icon.icon-framework-twcss{ background-position-x: -6.5rem; } /* Icons of tab */ .icon[class*="icon-tab"]{ width: 1rem; height: 1rem; background-position-y: -15.25rem; } .icon.icon-tab-website{ background-position-x: 0; } .icon.icon-tab-layout{ background-position-x: -2rem; } .icon.icon-tab-cms{ background-position-x: -4rem; } /* Icons of statics */ .icon[class*="icon-statics"]{ width: .875rem; height: .875rem; background-position-y: -17.25rem; } .icon.icon-statics-star{ background-position-x: 0; } .icon.icon-statics-download{ background-position-x: -1.875rem; } /* Icons of playground */ .icon[class*="icon-playground"]{ width: 1.25rem; height: 1.25rem; background-position-y: -19.125rem; } .icon.icon-playground-wind{ background-position-x: 0; } .icon.icon-playground-snow{ background-position-x: -2.25rem; } .icon.icon-playground-cherry{ background-position-x: -4.5rem; } .icon.icon-playground-chip{ background-position-x: -6.75rem; } /* Icons of courses */ .icon[class*="icon-course"]{ width: 1.5rem; height: 1.5rem; background-position-y: -21.875rem; } .icon.icon-course-guide-basic{ background-position-x: 0; } .icon.icon-course-guide-pro{ background-position-x: -2.5rem; } .icon.icon-course-shopify-theme{ background-position-x: -5rem; } .icon.icon-course-website-design{ background-position-x: -7.5rem; } /* Icons of specialized courses */ .icon[class*="icon-specialized-course"]{ width: 1.5rem; height: 1.5rem; background-position-y: -24.375rem; } .icon.icon-specialized-course-seo{ background-position-x: 0; } .icon.icon-specialized-course-smm{ background-position-x: -2.5rem; } .icon.icon-specialized-course-creation{ background-position-x: -5rem; } .icon.icon-specialized-course-e-commerce{ background-position-x: -7.5rem; } .icon.icon-specialized-course-workflow{ background-position-x: -10rem; } /* Icons of form */ .icon[class*="icon-form"]{ width: 1.25rem; height: 1.25rem; background-position-y: -27.375rem; } .icon.icon-form-arrow{ background-position-x: 0; } .icon.icon-form-search{ background-position-x: -2.25rem; } .icon.icon-form-read{ background-position-x: -4.5rem; } .icon.icon-form-submit{ background-position-x: -6.75rem; } /* Text of icon */ .icon[class*="text-playground"]{ width: 6.75rem; height: 1rem; } .icon.text-playground-wind{ background-position-y: -30.125rem; } .icon.text-playground-snow{ background-position-y: -31.125rem; } .icon.text-playground-cherry{ background-position-y: -32.125rem; } .icon.text-playground-chip{ background-position-y: -33.125rem; } }
第四步:定义原子类

最后,让我们定义一个原子类;用做示范的话,隐藏滚动条是再适合不过的例子了:

@/styles/globals.css
@utility hide-scrollbar { scrollbar-width: none; -ms-overflow-style: none; &::-webkit-scrollbar { display: none; } }

如果你要定义的原子类很多,那么推荐将其放入原子类层,动画关键帧也应存放于此处:

@/styles/globals.css
@layer utilities { /* Utilities */ hide-scrollbar { scrollbar-width: none; -ms-overflow-style: none; &::-webkit-scrollbar { display: none; } } /* Keyframes */ @keyframes reveal-clip { 0%, 99% { overflow: visible; } 100% { overflow: hidden; } } }

@layer utilities 层在合成后的样式表中天然位于 @layer base@layer components 之后,因此通常具有更高的覆盖性。

小提示:@layer components 内自定义的样式属于静态 CSS,会直接注入到输出文件;你可以在模板中使用 class={`icon icon-nav-footer-contact-${...}`} 这样的动态类名来引用它们。相对地,我们先前提到过,Tailwind 的内置 utilities 以及任意变体类名,只有在你的源码中以字面量出现(或在 safelist 中列出)时,Tailwind 才会生成对应的样式规则。若你在 @layer utilities 中定义了自定义工具类,它们同样是静态规则,是否被保留取决于你的构建裁剪配置,而不是 Tailwind 的类名扫描。

4.3.2 全局组件 Background

现在让我们从 @/components/global/ 目录下的全局通用组件开始开发。

首先需要为全站添加背景,请在 _documents.js 中修改 <body> 的 className,定义背景色与字色:

@/pages/_document.js
import { Html, Head, Main, NextScript } from 'next/document' export default function Document() { return ( <Html lang="en"> <Head> <link rel="icon" href="/favicon.ico" /> </Head> <body className="bg-slate-100 text-primary"> <Main /> <NextScript /> </body> </Html> ) }

然后编辑 Layout.js ,为其添加定位以方便子元素获取宽高,再将 <main> 注掉方便观察:

@/components/Layout.js
export default function Layout({ globalData, children }) { return ( <div className={`${sen.className} relative`}> <Background globalData={globalData}/> {/* <main>{ children }</main> */} {/* 注掉这一行 */} <Subscription globalData={globalData}/> <Footer globalData={globalData}/> </div> ) }

接着将 课程资源包 内项目文件中的 public/image 目录直接复制到项目的 public 目录下,然后打开 @/components/global/Background,为其添加如下代码:

为确保课程讲解顺序,这块暂时不用理解每行代码是做什么的,复制粘贴跳过即可。

@/components/global/Background.js
// Import Next components import Image from 'next/image' export default function Background({ globalData }) { return ( <div className="absolute -z-50 w-full h-[calc(100%+6.25rem)] -top-25 overflow-hidden lg:h-[calc(100%+6rem)] lg:-top-24"> <div className="relative w-[200vw] aspect-5/4 left-[-50vw] blur-3xl"> <span className="absolute block w-[31.25%] aspect-square right-[9.8958%] top-[-25%] rounded-full bg-radial from-blue-300 to-blue-400/0 opacity-20"></span> <span className="absolute block w-[31.25%] aspect-square left-0 top-[18.75%] rounded-full bg-radial from-blue-300 to-blue-400/0 opacity-10"></span> <span className="absolute block w-[31.25%] aspect-square right-[4.0625%] top-[33.3333%] rounded-full bg-radial from-blue-300 to-blue-400/0 opacity-25"></span> <span className="absolute block w-[31.25%] aspect-square left-[8.3333%] top-[54.6875%] rounded-full bg-radial from-blue-300 to-blue-400/0 opacity-15"></span> <span className="absolute block w-[31.25%] aspect-square right-0 top-[78.125%] rounded-full bg-radial from-blue-300 to-blue-400/0 opacity-15"></span> </div> </div> ) }

可以看到背景已经应用,但是高度没被撑开,接下来我们继续完成各个组件的内容。

4.3.3 全局组件 Subscription

这里我们需要深入地理解 Tailwind CSS 原子类名撰写逻辑,所以节奏会放缓一点。

如果你看到这里已经开始晕头转向了,那么接下来开始除了标注的部分以外,你可以尽可能一行行地照抄这些 className ,并切回浏览器查看样式变化,以方便你理解 Tailwind CSS 原子类名对布局的影响。

此外,不要完全按照设计稿中给到的字重、阴影、字间距等参数设置你的布局,请务必以视觉效果对齐设计稿为准。

现在我们需要先回到浏览器,在开发者工具中打开设备工具栏(你可以使用组合键 ⌘ / CTRL + ⇧ / SHIFT + M 快速访问),然后模拟 iPhone XR,或是手动将分辨率降低到更低、更安全的 1080p @ 1x 设计比例,即 360 x 640:

Tailwind CSS - 在浏览器中模拟移动端 | Rainy Design Studio 雨点设计工作室

一切准备就绪,开始开发相对简单的 Subscription:

@/components/global/Subscription.js
// Import utilities import { handleFormSubmit } from '@/utils/handleFormSubmit' export default function Subscription({ globalData }) { return ( <div> {/* 外部容器与背景 */} <div> {/* 内部容器,居中定位与布局 */} <div> {/* 标题与段落 */} <h4>{globalData.subscription.heading}</h4> <p>{globalData.subscription.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/subscriptions')}> <input name="subscription" placeholder={globalData.subscription.inputPlaceholder} required/> <button type="submit"></button> </form> </div> </div> ) }

我们遵循 Tailwind CSS 移动端优先的开发逻辑,从左到右查看设计稿,不难注意到 Subscription 是一个圆角矩形 24px 的容器,其内边距为上下 72px,左右 24px,背景色是 Tailwind Color 预定义的 slate-50

Tailwind CSS - 对照设计稿 - Subscription | Rainy Design Studio 雨点设计工作室

让我们为容器添加对应的 className,效果如下:

代码片段


... <div className="py-18 px-6 rounded-3xl bg-slate-50"> {/* 外部容器与背景 */} <div> {/* 内部容器,居中定位与布局 */} <div> {/* 标题与段落 */} <h4>{globalData.subscription.heading}</h4> ...

渲染结果


Tailwind CSS - Subscription 第一步 | Rainy Design Studio 雨点设计工作室

接下来进一步完善其他部分:标题为 24px 且字重为 medium,段落上边距为 16px :

代码片段


... <div className="py-18 px-6 rounded-3xl bg-slate-50"> {/* 外部容器与背景 */} <div> {/* 内部容器,居中定位与布局 */} <div> {/* 标题与段落 */} <h4 className="text-2xl font-medium">{globalData.subscription.heading}</h4> <p className="mt-4">{globalData.subscription.description}</p> </div> ... </div> ...

渲染结果


Tailwind CSS - Subscription 第二步 | Rainy Design Studio 雨点设计工作室

接下来处理样式比较复杂的 form;整个 <form> 的上边距是 24px,其中包含了 <input><button> 两个元素:

Tailwind CSS - input | Rainy Design Studio 雨点设计工作室

我们在 Adobe XD 中打开图层列表,会看到 <input> 分为三个背景,对应阴影层 shade、底部的白色和中间的渐变色,而且 <button> 除了径向渐变的圆球色以外中间还有一个小图标,所以我们要添加一些 HTML 元素来实现效果。

通常情况下一个 input 并不需要这么复杂的样式设计,在这里我们仅用做参考,直接粘贴代码。

代码片段


... <div className="py-18 px-6 rounded-3xl bg-slate-50"> {/* 外部容器与背景 */} <div> {/* 内部容器,居中定位与布局 */} <div> {/* 标题与段落 */} <h4 className="text-2xl font-medium">{globalData.subscription.heading}</h4> <p className="mt-4">{globalData.subscription.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/subscriptions')} className="relative inline-block mt-4"> <span className="absolute w-[calc(100%-2rem)] h-[calc(100%-1rem)] left-4 bottom-0.5 rounded-full shadow-md shadow-slate-300/67"></span> {/* 阴影层 */} <span className="absolute w-[calc(100%-.25rem)] h-[calc(100%-.3125rem)] left-0.5 top-0.5 rounded-full bg-linear-to-b from-slate-300 to-slate-100"></span> {/* 背景渐变 */} <input name="subscription" placeholder={globalData.subscription.inputPlaceholder} required className="relative h-13 min-w-72 rounded-full border-white border-2 border-b-3 pl-6 pr-17 focus:outline-slate-400"/> <button type="submit" className="absolute aspect-square h-[calc(100%-.25rem)] top-0.5 right-0.5 p-3.5 rounded-full bg-radial-[at_75%_25%] from-[#bdcde1] to-[#9fb5d6]"> <span className="icon icon-form-submit block"></span> </button> </form> </div> </div> ...

渲染结果


Tailwind CSS - Subscription 第三步 | Rainy Design Studio 雨点设计工作室

此外,这里的阴影层我们后续还要复用,所以可以在 globals.css 里定义一下,这里我们使用 Tailwind CSS 做演示:

@/styles/globals.css
... @layer components { ... .customized-shadow { @apply absolute w-[calc(100%-2rem)] h-[calc(100%-1rem)] left-4 bottom-0.5 rounded-full shadow-md shadow-slate-300/67; } }

这样一来我们便可以直接将其替换为组件类名了:

@/components/Subscription.js
<div className="py-16 px-6 rounded-3xl bg-slate-50"> {/* 外部容器与背景 */} <div> {/* 内部容器,居中定位与布局 */} <div> {/* 标题与段落 */} <h4 className="text-2xl font-medium">{globalData.subscription.heading}</h4> <p className="mt-4">{globalData.subscription.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/subscriptions')} className="relative inline-block mt-4"> <span className="customized-shadow"></span> {/* 阴影层 */} <span className="absolute w-[calc(100%-.25rem)] h-[calc(100%-.3125rem)] left-0.5 top-0.5 rounded-full bg-linear-to-b from-slate-300 to-slate-100"></span> {/* 背景渐变 */} <input name="subscription" placeholder={globalData.subscription.inputPlaceholder} required className="relative h-13 min-w-72 rounded-full border-white border-2 border-b-3 pl-6 pr-17 focus:outline-slate-400"/> <button type="submit" className="absolute aspect-square h-[calc(100%-.25rem)] top-0.5 right-0.5 p-3.5 rounded-full bg-radial-[at_75%_25%] from-[#bdcde1] to-[#9fb5d6] cursor-pointer"> <span className="icon icon-form-submit block"></span> </button> </form> </div> </div>

面对样式比较多且需要复用的类名,我们可以在组件层面利用 @apply 定义并灵活调用。

继续查看设计稿完成宽屏的部分,我们会发现断点从 md:,也就是视窗宽度 ≥ 48rem(768px) 开始容器的内边距变宽了,这里调整一下:

... <div className="py-18 px-6 rounded-3xl bg-slate-50 md:px-9"> {/* 外部容器与背景 */} ...

接下来是 lg:,可以看到布局跟着变为了横向,这里我们仅需对父容器添加数个类名就能完成布局的设置:

... <div className="py-18 px-6 rounded-3xl bg-slate-50 md:px-9 lg:p-18"> {/* 外部容器与背景 */} <div className="justify-between items-center lg:flex"> {/* 内部容器,居中定位与布局 */} <div className="mr-2"> {/* 标题与段落 */} ...

从断点 2xl: 开始内部容器要定位居中,所以我们要为其添加最大宽度与左右外边距:

... <div className="py-16 px-6 rounded-3xl bg-slate-50"> {/* 外部容器与背景 */} <div className="justify-between items-center max-w-349 mx-auto lg:flex"> {/* 内部容器,居中定位与布局 */} <div className="mr-2"> {/* 标题与段落 */} ...

这样一来,Subscription 的响应式布局也已开发完毕了:

@/components/global/Subscription.js
// Import utilities import { handleFormSubmit } from '@/utils/handleFormSubmit' export default function Subscription({ globalData }) { return ( <div className="py-18 px-6 rounded-3xl mt-30 bg-slate-50 md:px-9 lg:p-18"> <div className="justify-between items-center max-w-349 mx-auto lg:flex"> <div className="mr-2"> <h4 className="text-2xl font-medium">{globalData.subscription.heading}</h4> <p className="mt-4">{globalData.subscription.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/subscriptions')} className="relative inline-block mt-4"> <span className="customized-shadow"></span> <span className="absolute w-[calc(100%-.25rem)] h-[calc(100%-.3125rem)] left-0.5 top-0.5 rounded-full bg-linear-to-b from-slate-300 to-slate-100"></span> <input name="subscription" placeholder={globalData.subscription.inputPlaceholder} required className="relative h-13 min-w-72 rounded-full border-white border-2 border-b-3 pl-6 pr-17 focus:outline-slate-400"/> <button type="submit" className="absolute aspect-square h-[calc(100%-.25rem)] top-0.5 right-0.5 p-3.5 rounded-full bg-radial-[at_75%_25%] from-[#bdcde1] to-[#9fb5d6] cursor-pointer"> <span className="icon icon-form-submit block"></span> </button> </form> </div> </div> ) }

接下来让我们为 Footer 添加样式:

@/components/global/Footer.js
// Import Next components import Image from 'next/image' import Link from 'next/link' // Import libraries import Markdown from '@/lib/markdown' export default function Footer({ globalData }) { return ( <footer className="px-6 py-15 space-y-12"> <div> {/* 上半部分 */} <div className="space-y-6"> {/* 上半部 Logo */} <Image src="/logo/logo-footer.png" alt={`Logo${globalData.globalAddOn}`} width={60} height={40}/> <p className="text-xl font-medium">{globalData.footer.description}</p> </div> <div className="mt-12 space-y-12"> {/* 上半部联络方式与导航 */} <ul className="space-y-3"> <li className="uppercase font-medium">Contact</li> {globalData.contact && globalData.contact.map((contactItem, contactIndex) => ( <li key={contactIndex} className="flex items-center space-x-2"> <span className={`icon icon-footer-contact-${contactItem.type}`}></span> <a href={contactItem.link}>{contactItem.text}</a> </li> ))} </ul> <ul className="space-y-2"> <li className="font-medium">Navigation</li> <li><Link href="/">Home</Link></li> <li><Link href="/course/">Course</Link></li> <li><Link href="/blog/">Blog</Link></li> <li><Link href="/about/">About</Link></li> </ul> </div> </div> <div className="justify-between mt-12"> {/* 下半部分 */} <div className="text-secondary"> <Markdown data={globalData.footer.copyright} /> </div> <div className="flex items-center mt-6 space-x-3"> {globalData.social && globalData.social.map((socialItem, socialIndex) => ( <a key={socialIndex} href={socialItem.link} target="_blank" rel="noopener noreferrer"><span className={`icon icon-footer-social-${socialItem.type}`}></span></a> ))} </div> </div> </footer> ) }

我们将 copyright 套入了一个 div 并应用了 text-secondary 字色,以便修改 React Markdown 渲染后的样式。不过很明显的,它并没有将 HTML 渲染出来,而是直接将其作为字符串处理了,因此我们需要停止 Next.JS 项目运行并安装一个 rehype 插件以自动解析 HTML:

Terminal
yarn add rehype-raw # 使用 npm 管理则输入 npm install rehype-raw yarn dev

对应地修改 @/lib/markdown.js 中的代码:

@/lib/markdown.js
// Import dependencies import React from 'react' import ReactMarkdown from 'react-markdown' import rehypeRaw from 'rehype-raw' export default function Markdown({ data }) { if (!data) return null return <ReactMarkdown rehypePlugins={[rehypeRaw]}>{data}</ReactMarkdown> }

渲染结果如下:

Tailwind CSS - Footer 第一步 | Rainy Design Studio 雨点设计工作室

copyright 已经正确显示了,但我们会发现应用了 underline 类名的链接并没有显示下划线,在开发者工具中查看输出的 CSS 也没有发现对应的 utilities:

Tailwind CSS - Footer - 查看输出文件 | Rainy Design Studio 雨点设计工作室

还记得我们在本章节中 4.2.2 核心理念 小节中提到的概念吗?Tailwind CSS 仅扫描文件作为输入源,并检测匹配的类名进行输出,这意味着动态数据中包含的类名不会被渲染。

现在,我们希望 underline 应作为一个常用的类名被保留在 Tailwind CSS 输出文件中时,类似于 Tailwind CSS v3 的 safelist 逻辑,我们需要在 globals.css 中添加一个内联类名源—— @source inline

@/styles/globals.css
@import "tailwindcss"; @source "../pages/**.*"; @source inline("underline"); ...

保存过后,我们就能看到样式成功匹配好了。

接下来来到断点 md: ,我们能看到 Footer 的横向内边距变成了 36px,以及两个菜单和下方内容都并为一行了,这里实现一下:

... <footer className="px-6 py-15 space-y-12 md:px-9"> <div> {/* 上半部分 */} <div className="space-y-6"> {/* 上半部 Logo */} <Image src="/logo/logo-footer.png" alt={`Logo${globalData.globalAddOn}`} width={60} height={40}/> <p className="text-xl font-medium">{globalData.footer.description}</p> </div> <div className="mt-12 space-y-12 justify-between md:flex md:space-y-0"> {/* 上半部联络方式与导航 */} ... <div className="justify-between mt-12 md:flex"> {/* 下半部分 */} <div className="text-secondary"> <Markdown data={globalData.footer.copyright} /> </div> <div className="flex items-center mt-6 space-x-3 md:mt-0"> ...

再来到断点 lg:,我们能看到上半部分并为了一行,实现一下:

... <footer className="px-6 py-15 space-y-12 md:px-9 lg:px-18"> <div className="lg:flex"> {/* 上半部分 */} <div className="space-y-6 lg:mr-auto"> {/* 上半部 Logo */} <Image src="/logo/logo-footer.png" alt={`Logo${globalData.globalAddOn}`} width={60} height={40}/> <p className="text-xl font-medium">{globalData.footer.description}</p> </div> <div className="mt-12 space-y-12 justify-between md:flex md:space-y-0 lg:mt-0 lg:space-x-18"> {/* 上半部联络方式与导航 */} ...

最后,我们要为 2xl: 断点设定容器最大宽度,由于外部容器宽度不会大于 1536px(96rem) ,这样先前在 theme 层中定义的 max-w-8xl 就派上用场了;不过由于我们的设计稿中有非常多的容器会用到 max-w-8xl mx-auto px-6 md:px-9 lg:px-18 这样一组原子类名,所以我们可以直接在 @layer components 层中定义,然后应用到需要的地方:

@/styles/globals.css
... @layer components { ... .customized-container { @apply max-w-8xl mx-auto px-6 md:px-9 lg:px-18; } }

比如为 <footer> 添加该类名:

... <footer className="customized-container py-15 space-y-12"> <div className="lg:flex"> {/* 上半部分 */} ...

至此 Footer 也已开发完毕。

4.3.5 全局组件 Nav

接下来来到比较困难的部分:顶部导航。

这里我们先来查看一个设计细节,请在 Adobe XD 查看设计稿,在选中画板的情况下按下 ⌘ / Ctrl + Enter 查看预览,可以看到 Nav 在悬停时有一个动画效果:

Tailwind CSS - 查看设计稿动画原型 | Rainy Design Studio 雨点设计工作室

回到设计稿,选中顶部导航能看到右上角有两个状态,点击 Hover state 就会切换到刚才出现的下拉菜单了:

Tailwind CSS - 切换组件状态 - navigation | Rainy Design Studio 雨点设计工作室

此外还需要查看一下桌面端的 Contact Dialog:

Tailwind CSS - 切换组件状态 - navigation - 桌面端 | Rainy Design Studio 雨点设计工作室

现在我们面临两种方案选择,一种是响应式布局,即使用同一套 DOM 结构,通过 Tailwind 的响应式修饰符(如 md:lg:)在不同屏幕尺寸下应用不同样式;另一种则是传统的写两套 HTML ,分别对应移动端和桌面端。这里选择前者。

首先,我们需要在 Strapi 后台将 Nav 中的联络封面传好,我们打开 课程资源包 ,找到 网站资源/Global 目录,将这张图片上传到 Strapi 媒体库中的 0.Global 目录下,然后编辑 Alternative text 为 "Nav contact cover":

Tailwind CSS - 上传图片到 0.Global | Rainy Design Studio 雨点设计工作室

Tailwind CSS - 填写图片 Alt 值 | Rainy Design Studio 雨点设计工作室

现在我们需要先将所有 HTML 写好:

@/components/global/Nav.js
// Import Next components import Link from 'next/link' import Image from 'next/image' // Import utilities import { handleFormSubmit } from '@/utils/handleFormSubmit' export default function Nav({ globalData }) { return ( <nav> {/* 导航条 */} <span></span> {/* 菜单背景 */} <div> {/* 导航条背景 */} <span></span> {/* 下层阴影 */} <span></span> {/* 上层着色 */} </div> <div> {/* 内容容器 */} <div> {/* Logo 容器 */} <Image src="/logo/logo.svg" alt={`logo${globalData.globalAddOn}`} width={128} height={128} /> </div> <ul> <li> <Link href="/"> <span></span> Home </Link> </li> <li> <Link href="/course"> <span></span> Course </Link> </li> <li> <Link href="/blog"> <span></span> blog </Link> </li> <li> <Link href="/about"> <span></span> About </Link> </li> </ul> <div className="relative hidden lg:block"> {/* 联络按钮容器,仅桌面端显示 */} <span></span> {/* 自定义阴影效果 */} <button>Contact</button> </div> <div> {/* 联络与社媒 */} <div> <div> {/* 联络弹窗 */} <span> {/* 联络弹窗背景,仅桌面端显示 */} <span></span> <span></span> </span> <div> {/* 联络内容 */} <div> {/* 标题与段落 */} <h3>{globalData.navigation.heading}</h3> <p>{globalData.navigation.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/contacts')}> {/* 表单 */} <input name="name" placeholder={`${globalData.navigation.placeholderOfName} *`} required/> <input name="wechatOrQQ" placeholder={`${globalData.navigation.placeholderOfWechatOrQQ} *`} required /> <input name="tel" placeholder={globalData.navigation.placeholderOftel} /> <textarea name="msg" placeholder={`${globalData.navigation.placeholderOfMessage} *`} required></textarea> <button type="submit">{globalData.navigation.submitButtonText}</button> </form> </div> <div> {/* 插图容器 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${globalData.navigation.image.url}`} alt={`${globalData.navigation.image.alternativeText}${globalData.globalAddOn}`} width={globalData.navigation.image.width} height={globalData.navigation.image.height} /> <span></span> {/* 关闭按钮,仅桌面端显示 */} </div> </div> <ul> {/* 社媒 */} <li> <a href="" target="_blank" rel="noopener noreferrer"> <span></span> </a> </li> <li> <a href="" target="_blank" rel="noopener noreferrer"> <span></span> </a> </li> <li> <a href="" target="_blank" rel="noopener noreferrer"> <span></span> </a> </li> <li> <a href="" target="_blank" rel="noopener noreferrer"> <span></span> </a> </li> <li> <a href="" target="_blank" rel="noopener noreferrer"> <span></span> </a> </li> </ul> </div> </div> </div> <span></span> {/* 渐变遮罩 */} <span></span> {/* 指示器 */} </nav> ) }

为了方便观察导航组件的开发效果,我们可以暂时在 Layout.js 中注释掉 Subscription 和 Footer 组件:

@/components/Layout.js
... export default function Layout({ globalData, children }) { return ( <div className={`${sen.className} relative`}> <Background globalData={globalData}/> <Nav globalData={globalData}/> {/* <main>{ children }</main> */} {/* <Subscription globalData={globalData}/> */} {/* <Footer globalData={globalData}/> */} </div> ) }

回到 Nav.js ,先完成未展开的部分:

代码参考


... export default function Nav({ globalData }) { return ( <nav className="fixed z-50 w-full top-0 px-6 pt-6 pb-20"> {/* 导航条 */} <span className="absolute block w-full h-[calc(100%-4rem)] inset-0 rounded-b-3xl bg-slate-50 shadow-2xl shadow-primary/25"></span> {/* 菜单背景 */} <div className="absolute w-[calc(100%-3rem)] h-12 left-6"> {/* 导航条背景 */} <span className="customized-shadow"></span> {/* 下层阴影 */} <span className="absolute block size-full rounded-full bg-slate-50"></span> {/* 上层着色 */} </div> <div className="hidden"> {/* 内容容器,暂时隐藏以便先观察导航条背景,后续移除 */} ...

渲染结果


Tailwind CSS - Navigation - 渲染结果 - 导航条 | Rainy Design Studio 雨点设计工作室

此时可以看到导航由于没有内容而导致背景塌陷了,这是正常情况,接下来就让我们为其填充内容。与此同时,先前定义的 .customized-shadow 类名也已派上了用场:

@/styles/globals.css


... @layer components { ... /* Customized components */ .customized-shadow { @apply absolute w-[calc(100%-2rem)] h-[calc(100%-1rem)] left-4 bottom-0.5 rounded-full shadow-md shadow-slate-300/67; } .customized-container { @apply max-w-8xl mx-auto px-6 md:px-9 lg:px-18; } .customized-nav-list { @apply flex items-center h-12 p-4 rounded-3xl bg-primary/5 uppercase; } } ...

@/components/global/Nav.js


... <div className="relative"> {/* 内容容器 */} <div className="relative h-12 flex justify-center items-center"> {/* Logo 容器 */} <Image src="/logo/logo.svg" alt={`logo${globalData.globalAddOn}`} width={128} height={128} className="relative h-6 w-auto aspect-square" /> </div> <ul className="grid grid-cols-2 gap-4 mt-4"> {/* 导航菜单 */} <li> <Link href="/" className="customized-nav-list"> <span className="icon icon-nav-list-home mr-1.5"></span> Home </Link> </li> <li> <Link href="/course" className="customized-nav-list"> <span className="icon icon-nav-list-course mr-1.5"></span> Course </Link> </li> <li> <Link href="/blog" className="customized-nav-list"> <span className="icon icon-nav-list-blog mr-1.5"></span> blog </Link> </li> <li> <Link href="/about" className="customized-nav-list"> <span className="icon icon-nav-list-about mr-1.5"></span> About </Link> </li> </ul> <div className="relative hidden lg:block"> {/* 联络按钮容器,仅桌面端显示 */} <span></span> {/* 自定义阴影效果 */} <button>Contact</button> </div> <div> {/* 联络与社媒 */} ... </div> ...

渲染结果


Tailwind CSS - Navigation - 渲染结果 - 菜单内容 | Rainy Design Studio 雨点设计工作室

最后处理联络表单与社交媒体图标部分。由于内容可能超出视窗高度,我们添加了滚动容器,并在底部添加渐变遮罩和指示器(Indicator),提示用户可以向下滚动查看更多内容:

@/styles/globals.css


... @layer components { ... /* Customized components */ .customized-shadow { @apply absolute w-[calc(100%-2rem)] h-[calc(100%-1rem)] left-4 bottom-0.5 rounded-3xl shadow-md shadow-slate-300/67; } .customized-nav-list { @apply flex items-center h-12 p-4 rounded-3xl bg-primary/5 uppercase lg:bg-transparent; } .customized-form { @apply appearance-none resize-none w-full h-12 p-4 rounded-3xl bg-primary/5; @variant focus { @apply outline-secondary -outline-offset-1; } } .customized-social { @apply h-12 p-3 rounded-3xl bg-primary/5; } .customized-contact-button { @apply relative h-10 px-6 size-full border-2 border-white rounded-full bg-linear-to-b from-slate-300 to-slate-100 font-semibold uppercase cursor-pointer; } } ...

@/components/global/Nav.js


... <div className="relative"> {/* 内容容器 */} ... <div className="relative hidden lg:block"> {/* 联络按钮容器,仅桌面端显示 */} <span className="customized-shadow"></span> {/* 自定义阴影效果 */} <button className="customized-contact-button relative" onClick={() => setNavStatus(!navStatus)}>Contact</button> </div> <div className="relative mt-6"> {/* 联络与社媒 */} <div className="max-h-[calc(100vh-18rem)] overflow-x-hidden overflow-y-auto"> {/* 滚动容器 */} <div> {/* 联络弹窗 */} <span> {/* 联络弹窗背景,仅桌面端显示 */} <span></span> <span></span> </span> <div> {/* 联络内容 */} <div> {/* 标题与段落 */} <h3 className="font-medium text-center">{globalData.navigation.heading}</h3> <p className="hidden lg:block">{globalData.navigation.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/contacts')} className="grid gap-4 mt-4"> {/* 表单 */} <input name="name" placeholder={`${globalData.navigation.placeholderOfName} *`} required className="customized-form"/> <input name="wechatOrQQ" placeholder={`${globalData.navigation.placeholderOfWechatOrQQ} *`} required className="customized-form" /> <input name="tel" placeholder={globalData.navigation.placeholderOftel} className="customized-form" /> <textarea name="msg" placeholder={`${globalData.navigation.placeholderOfMessage} *`} required className="customized-form min-h-24"></textarea> <button type="submit" className="customized-form py-3 font-medium text-center uppercase text-primary cursor-pointer">{globalData.navigation.submitButtonText}</button> </form> </div> <div className="hidden lg:block"> {/* 插图容器,仅桌面端显示 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${globalData.navigation.image.url}`} alt={`${globalData.navigation.image.alternativeText}${globalData.globalAddOn}`} width={globalData.navigation.image.width} height={globalData.navigation.image.height} /> <span></span> {/* 关闭按钮,仅桌面端显示 */} </div> </div> <ul className="grid grid-cols-5 gap-4 mt-6 pb-6"> {/* 社媒 */} <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-website block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-bilibili block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-wechat block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-email block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer"> <span className="icon icon-nav-contact-more block mx-auto"></span> </a> </li> </ul> </div> </div> </div> <span className="relative block w-full h-6 -mt-6 bg-linear-to-b from-slate-50/0 via-75% via-slate-50 to-slate-50"></span> {/* 渐变遮罩 */} <span className="block w-30 h-1.5 rounded-full mx-auto bg-secondary/33"></span> {/* 指示器 */} ...

渲染结果


Tailwind CSS - Navigation - 渲染结果 - 菜单内容 2 | Rainy Design Studio 雨点设计工作室

现在要实现组件状态的切换,此时我们又面临两种解决方案的选择:

  1. React hook 方案: 使用 useState() 管理菜单状态,通过条件渲染控制样式;
  2. CSS 方案: 使用 checkbox 的 :checked 状态配合 Tailwind 的状态修饰符,仅用少量 JS 同步状态

以下是两者对比:

指标React hook 方案CSS 方案
重渲染范围整个组件树仅 checkbox 元素
类名计算次数7 次/渲染0 次(静态)
Virtual DOM diff完整 diff最小 diff
浏览器重绘JavaScript 触发CSS 原生触发
GPU 加速
技术要求需要理解 React Hooks(useState)和 JSX 条件渲染需要掌握 CSS 伪类选择器和 Tailwind 的状态修饰符

从表格可以看出,CSS 方案 在性能上略有优势,但 React hook 方案的代码逻辑更直观易懂。对于本项目的规模,两者的性能差异可以忽略不计。出于代码可维护性考虑,实际项目中通常使用前者

我们将其命名为 Nav.jsNav2.js 两个组件,并提供代码参考:

@/components/global/Nav.js
// Import React hooks import { useState } from 'react' // Import Next components import Link from 'next/link' import Image from 'next/image' // Import utilities import { handleFormSubmit } from '@/utils/handleFormSubmit' export default function Nav({ globalData }) { const [navStatus, setNavStatus] = useState(false); return ( <nav className={`fixed z-50 w-full top-0 px-6 pt-6 pb-20 overflow-hidden duration-1000 ease-in-out ${navStatus ? 'max-h-200' : 'max-h-20'}`}> {/* 导航条 */} <span className="absolute z-10 block w-[calc(100%-3rem)] h-12 top-6 left-6" onClick={() => setNavStatus(!navStatus)} ></span> {/* 点击切换 navStatus 状态,以实现 navStatus 控制的样式变更 */} <span className={`absolute block w-full h-[calc(100%-4rem)] inset-0 rounded-b-3xl duration-300 ease-in ${navStatus ? 'bg-slate-50 shadow-xl shadow-primary/12' : ''}`}></span> {/* 菜单背景 */} <div className="absolute w-[calc(100%-3rem)] h-12 left-6"> {/* 导航条背景 */} <span className={`customized-shadow duration-300 ease-in ${navStatus ? 'hidden' : ''}`}></span> {/* 下层阴影 */} <span className={`absolute block size-full rounded-full duration-300 ease-in ${navStatus ? 'bg-primary/5' : 'bg-slate-50'}`}></span> {/* 上层着色 */} </div> <div className={`relative duration-1000 ease-out ${navStatus ? 'max-h-200' : 'max-h-0'}`}> {/* 内容容器 */} <div className="relative h-12 flex justify-center items-center"> {/* Logo 容器 */} <Image src="/logo/logo.svg" alt={`logo${globalData.globalAddOn}`} width={128} height={128} className="relative h-6 w-auto aspect-square" /> </div> <ul className={`grid grid-cols-2 gap-4 mt-4 duration-300 ease-in ${navStatus ? 'opacity-100' : 'opacity-0'}`}> {/* 导航菜单 */} <li> <Link href="/" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className="icon icon-nav-list-home mr-1.5"></span> Home </Link> </li> <li> <Link href="/course" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className="icon icon-nav-list-course mr-1.5"></span> Course </Link> </li> <li> <Link href="/blog" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className="icon icon-nav-list-blog mr-1.5"></span> blog </Link> </li> <li> <Link href="/about" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className="icon icon-nav-list-about mr-1.5"></span> About </Link> </li> </ul> <div className="relative hidden lg:block"> {/* 联络按钮容器,仅桌面端显示 */} <span></span> {/* 自定义阴影效果 */} <button>Contact</button> </div> <div className={`relative mt-6 duration-300 ease-in ${navStatus ? 'opacity-100' : 'opacity-0'}`}> {/* 联络与社媒 */} <div className="max-h-[calc(100vh-18rem)] overflow-x-hidden overflow-y-auto"> {/* 滚动容器 */} <div> {/* 联络弹窗 */} <span> {/* 联络弹窗背景,仅桌面端显示 */} <span></span> <span></span> </span> <div> {/* 联络内容 */} <div> {/* 标题与段落 */} <h3 className="font-medium text-center">{globalData.navigation.heading}</h3> <p className="hidden lg:block">{globalData.navigation.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/contacts')} className="grid gap-4 mt-4"> {/* 表单 */} <input name="name" placeholder={`${globalData.navigation.placeholderOfName} *`} required className="customized-form"/> <input name="wechatOrQQ" placeholder={`${globalData.navigation.placeholderOfWechatOrQQ} *`} required className="customized-form" /> <input name="tel" placeholder={globalData.navigation.placeholderOftel} className="customized-form" /> <textarea name="msg" placeholder={`${globalData.navigation.placeholderOfMessage} *`} required className="customized-form min-h-24"></textarea> <button type="submit" className="customized-form py-3 font-medium text-center uppercase text-primary cursor-pointer">{globalData.navigation.submitButtonText}</button> </form> </div> <div className="hidden lg:block"> {/* 插图容器,仅桌面端显示 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${globalData.navigation.image.url}`} alt={`${globalData.navigation.image.alternativeText}${globalData.globalAddOn}`} width={globalData.navigation.image.width} height={globalData.navigation.image.height} /> <span></span> {/* 关闭按钮,仅桌面端显示 */} </div> </div> <ul className="grid grid-cols-5 gap-4 mt-6 pb-8"> {/* 社媒 */} <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-website block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-bilibili block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-wechat block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-email block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer"> <span className="icon icon-nav-contact-more block mx-auto"></span> </a> </li> </ul> </div> </div> </div> <span className={`absolute block w-full h-12 left-0 bottom-18 bg-linear-to-b from-slate-50/0 via-50% via-slate-50 to-slate-50 duration-300 ease-in ${navStatus ? 'opacity-100' : 'opacity-0'}`}></span> {/* 渐变遮罩 */} <span className={`absolute block w-30 h-1.5 rounded-full left-[calc(50%-3.75rem)] bottom-20 bg-secondary/33 duration-300 ease-in ${navStatus ? 'opacity-100' : 'opacity-0'}`} onClick={() => setNavStatus(false)}></span> {/* 指示器 */} </nav> ) }
@/components/global/Nav2.js
// Import React hooks import { useState } from 'react' // Import Next components import Link from 'next/link' import Image from 'next/image' // Import utilities import { handleFormSubmit } from '@/utils/handleFormSubmit' export default function Nav2({ globalData }) { const [navStatus, setNavStatus] = useState(false); return ( <nav className="group z-50 fixed w-full top-0 px-6 pt-6 pb-20 overflow-hidden duration-1000 ease-in-out max-h-20 has-peer-checked:max-h-200"> {/* 导航条 */} <input name="navStatus" type="checkbox" className="peer appearance-none absolute z-10 block w-[calc(100%-3rem)] h-12 top-6 left-6" checked={navStatus} onChange={(e) => setNavStatus(e.target.checked)} /> {/* 为 checkbox 添设 peer 类名,以实现状态控制的同级元素样式变更 */} <span className="absolute block w-full h-[calc(100%-4rem)] inset-0 rounded-b-3xl duration-300 ease-in group-has-peer-checked:bg-slate-50 group-has-peer-checked:shadow-xl group-has-peer-checked:shadow-primary/12"></span> {/* 菜单背景 */} <div className="absolute w-[calc(100%-3rem)] h-12 left-6"> {/* 导航条背景 */} <span className="customized-shadow opacity-100 duration-300 ease-in group-has-peer-checked:opacity-0"></span> {/* 下层阴影 */} <span className="absolute block size-full rounded-full bg-slate-50 group-has-peer-checked:bg-primary/5 duration-300 ease-in"></span> {/* 上层着色 */} </div> <div className="relative max-h-0 duration-1000 ease-out group-has-peer-checked:max-h-200"> {/* 内容容器 */} <div className="relative h-12 flex justify-center items-center"> {/* Logo 容器 */} <Image src="/logo/logo.svg" alt={`logo${globalData.globalAddOn}`} width={128} height={128} className="relative h-6 w-auto aspect-square" /> </div> <ul className="grid grid-cols-2 gap-4 mt-4 opacity-0 duration-300 ease-in group-has-peer-checked:opacity-100"> {/* 导航菜单 */} <li> <Link href="/" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className="icon icon-nav-list-home mr-1.5"></span> Home </Link> </li> <li> <Link href="/course" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className="icon icon-nav-list-course mr-1.5"></span> Course </Link> </li> <li> <Link href="/blog" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className="icon icon-nav-list-blog mr-1.5"></span> blog </Link> </li> <li> <Link href="/about" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className="icon icon-nav-list-about mr-1.5"></span> About </Link> </li> </ul> <div className="relative hidden lg:block"> {/* 联络按钮容器,仅桌面端显示 */} <span></span> {/* 自定义阴影效果 */} <button>Contact</button> </div> <div className="relative mt-6 opacity-0 duration-300 ease-in group-has-peer-checked:opacity-100"> {/* 联络与社媒 */} <div className="max-h-[calc(100vh-18rem)] overflow-x-hidden overflow-y-auto"> {/* 滚动容器 */} <div> {/* 联络弹窗 */} <span> {/* 联络弹窗背景,仅桌面端显示 */} <span></span> <span></span> </span> <div> {/* 联络内容 */} <div> {/* 标题与段落 */} <h3 className="font-medium text-center">{globalData.navigation.heading}</h3> <p className="hidden lg:block">{globalData.navigation.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/contacts')} className="grid gap-4 mt-4"> {/* 表单 */} <input name="name" placeholder={`${globalData.navigation.placeholderOfName} *`} required className="customized-form"/> <input name="wechatOrQQ" placeholder={`${globalData.navigation.placeholderOfWechatOrQQ} *`} required className="customized-form" /> <input name="tel" placeholder={globalData.navigation.placeholderOftel} className="customized-form" /> <textarea name="msg" placeholder={`${globalData.navigation.placeholderOfMessage} *`} required className="customized-form min-h-24"></textarea> <button type="submit" className="customized-form py-3 font-medium text-center uppercase text-primary cursor-pointer">{globalData.navigation.submitButtonText}</button> </form> </div> <div className="hidden lg:block"> {/* 插图容器,仅桌面端显示 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${globalData.navigation.image.url}`} alt={`${globalData.navigation.image.alternativeText}${globalData.globalAddOn}`} width={globalData.navigation.image.width} height={globalData.navigation.image.height} /> <span></span> {/* 关闭按钮,仅桌面端显示 */} </div> </div> <ul className="grid grid-cols-5 gap-4 mt-6 pb-8"> {/* 社媒 */} <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-website block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-bilibili block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-wechat block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-email block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer"> <span className="icon icon-nav-contact-more block mx-auto"></span> </a> </li> </ul> </div> </div> </div> <span className="absolute block w-full h-12 left-0 bottom-18 bg-linear-to-b from-slate-50/0 via-50% via-slate-50 to-slate-50 opacity-0 duration-300 ease-in group-has-peer-checked:opacity-100"></span> {/* 渐变遮罩 */} <span className="absolute block w-30 h-1.5 rounded-full left-[calc(50%-3.75rem)] bottom-20 bg-secondary/33 opacity-0 duration-300 ease-in group-has-peer-checked:opacity-100" onClick={() => setNavStatus(false)}></span> {/* 指示器 */} </nav> ) }
由于网站项目为 Next.JS Pages Router,因此在使用 React Hooks 时无需添加 'use client'

接下来我们继续完善响应式布局。回到设计稿查看 md: 断点的界面,查看布局发生的变化,再在刚才的 Nav.js 和 Nav2.js 中对应添加响应式类名以还原设计稿:

@/components/global/Nav.js


... <nav className={`fixed z-50 w-full top-0 px-6 pt-6 pb-20 overflow-hidden duration-1000 ease-in-out md:px-9 ${navStatus ? 'max-h-200' : 'max-h-20'}`}> {/* 导航条 */} <span className="absolute z-10 block w-[calc(100%-3rem)] h-12 top-6 left-6 md:w-[calc(100%-4rem)] md:left-9" onClick={() => setNavStatus(!navStatus)}></span> {/* 点击切换 navStatus 状态,以实现 navStatus 控制的样式变更 */} <span className={`absolute block w-full h-[calc(100%-4rem)] inset-0 rounded-b-3xl duration-300 ease-in ${navStatus ? 'bg-slate-50 shadow-xl shadow-slate-600/12' : ''}`}></span> {/* 菜单背景 */} <div className="absolute w-[calc(100%-3rem)] h-12 left-6 md:w-[calc(100%-4rem)] md:left-9"> {/* 导航条背景 */} ... <ul className={`grid grid-cols-2 gap-4 mt-4 duration-300 ease-in md:grid-cols-4 ${navStatus ? 'opacity-100' : 'opacity-0'}`}> {/* 导航菜单 */} ... <form onSubmit={(e) => handleFormSubmit(e, '/api/contacts')} className="grid gap-4 mt-4 md:grid-cols-3"> {/* 表单 */} ... <textarea name="msg" placeholder={`${globalData.navigation.placeholderOfMessage} *`} required className="customized-form min-h-24 md:col-span-3"></textarea> <button type="submit" className="customized-form py-3 font-medium text-center uppercase text-slate-600 cursor-pointer md:col-span-3">{globalData.navigation.submitButtonText}</button> ... </form> ...

@/components/global/Nav2.js


... <nav className="group z-50 fixed w-full top-0 px-6 pt-6 pb-20 overflow-hidden duration-1000 ease-in-out max-h-20 has-peer-checked:max-h-200 md:px-9"> {/* 导航条 */} <input name="navStatus" type="checkbox" className="peer appearance-none absolute z-10 block w-[calc(100%-3rem)] h-12 top-6 left-6 md:w-[calc(100%-4rem)] md:left-9" checked={navStatus} onChange={(e) => setNavStatus(e.target.checked)} /> {/* checkbox 作为 peer,通过 :checked 状态控制同级元素样式 */} <span className="absolute block w-full h-[calc(100%-4rem)] inset-0 rounded-b-3xl duration-300 ease-in group-has-peer-checked:bg-slate-50 group-has-peer-checked:shadow-xl group-has-peer-checked:shadow-primary/12"></span> {/* 菜单背景 */} <div className="absolute w-[calc(100%-3rem)] h-12 left-6 md:w-[calc(100%-4rem)] md:left-9"> {/* 导航条背景 */} ... <ul className="grid grid-cols-2 gap-4 mt-4 opacity-0 duration-300 ease-in group-has-peer-checked:opacity-100 md:grid-cols-4"> {/* 导航菜单 */} ... <form onSubmit={(e) => handleFormSubmit(e, '/api/contacts')} className="grid gap-4 mt-4 md:grid-cols-3"> {/* 表单 */} ... <textarea name="msg" placeholder={`${globalData.navigation.placeholderOfMessage} *`} required className="customized-form min-h-24 md:col-span-3"></textarea> <button type="submit" className="customized-form py-3 font-medium text-center uppercase text-slate-600 cursor-pointer md:col-span-3">{globalData.navigation.submitButtonText}</button> ... </form> ...

到了 lg: 断点(≥1024px)以上,导航的布局发生了重大变化:

  • 导航条变为横向布局
  • 联络表单以弹窗形式显示
  • 添加了桌面端的关闭按钮
  • 移除了移动端的社交媒体图标

因此需要对类名做大量调整。以下是支持完整响应式布局的 globals.cssNav.jsNav2.js 代码,并添加了路由判断,使菜单图标跟随页面路由切换填充图标:

@/styles/globals.css
@layer components { ... .customized-contact-button { @apply relative h-10 px-6 size-full border-2 border-white rounded-full bg-linear-to-b from-slate-300 to-slate-100 font-semibold uppercase cursor-pointer; } .customized-close-button { @apply absolute flex justify-center items-center size-12 right-4 top-4 rounded-full bg-slate-50/100 cursor-pointer duration-300 ease-in; @variant before { @apply absolute w-4 h-0.5 rounded-full bg-slate-600 rotate-45; } @variant after { @apply absolute w-4 h-0.5 rounded-full bg-slate-600 -rotate-45; } @variant hover { @apply bg-slate-600/10; } } }
@/components/global/Nav.js
// Import React hooks import { useState } from 'react' // Import Next components import Link from 'next/link' import Image from 'next/image' import { useRouter } from 'next/router' // Import utilities import { handleFormSubmit } from '@/utils/handleFormSubmit' export default function Nav({ globalData }) { const router = useRouter(); const [navStatus, setNavStatus] = useState(false); return ( <nav className={`fixed z-50 w-full top-0 px-6 pt-6 pb-20 overflow-hidden duration-1000 ease-in-out md:px-9 ${navStatus ? 'max-h-200 lg:overflow-visible' : 'max-h-20 lg:animate-[reveal-clip_1s_ease-in'}`}> {/* 导航条 */} <span className="absolute z-10 block w-[calc(100%-3rem)] h-12 top-6 left-6 md:w-[calc(100%-4rem)] md:left-9 lg:hidden" onClick={() => setNavStatus(!navStatus)} ></span> {/* 点击切换 navStatus 状态,以实现 navStatus 控制的样式变更 */} <span className={`absolute block w-full h-[calc(100%-4rem)] inset-0 rounded-b-3xl duration-300 ease-in lg:rounded-none lg:shadow-none lg:h-screen lg:backdrop-blur-lg lg:opacity-0 ${navStatus ? 'bg-slate-50 shadow-xl shadow-primary/12 lg:opacity-100' : ''}`}></span> {/* 菜单背景 */} <div className="absolute w-[calc(100%-3rem)] max-w-366 h-12 left-6 md:w-[calc(100%-4rem)] md:left-9 lg:h-18 2xl:left-[calc(50%-45.75rem)]"> {/* 导航条背景 */} <span className={`customized-shadow duration-300 ease-in ${navStatus ? 'hidden' : ''}`}></span> {/* 下层阴影 */} <span className={`absolute block size-full rounded-full duration-300 ease-in ${navStatus ? 'bg-primary/5' : 'bg-slate-50'}`}></span> {/* 上层着色 */} </div> <div className={`relative justify-between items-center max-w-366 transition-[max-height] duration-1000 ease-out lg:flex lg:px-9 ${navStatus ? 'max-lg:max-h-200' : 'max-lg:max-h-0'}`}> {/* 内容容器 */} <div className="relative h-12 flex justify-center items-center lg:h-18"> {/* Logo 容器 */} <Image src="/logo/logo.svg" alt={`logo${globalData.globalAddOn}`} width={128} height={128} className="relative h-6 w-auto aspect-square lg:h-8" /> </div> <ul className={`grid grid-cols-2 max-lg:gap-4 max-lg:mt-4 transition-opacity duration-300 ease-in md:grid-cols-4 lg:flex lg:justify-center ${navStatus ? 'opacity-100' : 'max-lg:opacity-0'}`}> {/* 导航菜单 */} <li> <Link href="/" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className={`icon icon-nav-list-home${router.pathname == '/' ? '-filled' : ''} mr-1.5`}></span> Home </Link> </li> <li> <Link href="/course" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className={`icon icon-nav-list-course${router.pathname == '/course' ? '-filled' : ''} mr-1.5`}></span> Course </Link> </li> <li> <Link href="/blog" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className={`icon icon-nav-list-blog${router.pathname.includes('/blog') || router.pathname.includes('/category') ? '-filled' : ''} mr-1.5`}></span> blog </Link> </li> <li> <Link href="/about" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className={`icon icon-nav-list-about${router.pathname == '/about' ? '-filled' : ''} mr-1.5`}></span> About </Link> </li> </ul> <div className="relative hidden lg:block"> {/* 联络按钮容器,仅桌面端显示 */} <span></span> {/* 自定义阴影效果 */} <button>Contact</button> </div> <div className={`relative max-lg:mt-6 transition-opacity duration-300 ease-in ${navStatus ? 'opacity-100' : 'opacity-0'} lg:absolute lg:w-240 lg:left-[calc(50%-30rem)] lg:top-39`}> {/* 联络与社媒 */} <div className="max-h-[calc(100vh-18rem)] h-[calc(100vh-18rem)] overflow-x-hidden overflow-y-auto"> {/* 滚动容器 */} <div className="lg:flex lg:h-full lg:p-3"> {/* 联络弹窗 */} <span className="hidden absolute size-full inset-0 rounded-[3rem] ring-white ring-2 backdrop-blur-2xl lg:block"> {/* 联络弹窗背景,仅桌面端显示 */} <span className="absolute block size-full top-0 rounded-[3rem] inset-shadow-[.1875rem_.375rem_1.5rem_white]"></span> <svg xmlns="http://www.w3.org/2000/svg" width="52.305" height="52.295" viewBox="-24 -24 96 96"> <path id="high_light_corner" d="M.545,52.295h0C.417,50.877.354,49.432.354,48a47.96,47.96,0,0,1,48-48c1.435,0,2.884.064,4.305.19A72.114,72.114,0,0,0,.545,52.295Z" className="absolute inset-6 fill-white opacity-100 blur-md"/> </svg> </span> <div className={`lg:flex lg:flex-col lg:w-102 lg:px-12 lg:py-15 lg:mr-3 lg:rounded-[2.5rem] lg:bg-white lg:overflow-scroll lg:transition-transform lg:duration-500 lg:ease-in ${navStatus ? 'lg:translate-y-0' : 'lg:-translate-y-8'}`}> {/* 联络内容 */} <div> {/* 标题与段落 */} <Image src="/logo/logo.svg" alt={`logo${globalData.globalAddOn}`} width={128} height={128} className="relative hidden w-15 aspect-square lg:block" /> <h3 className="font-medium text-center lg:mt-6 lg:text-left lg:text-2xl">{globalData.navigation.heading}</h3> <p className="hidden lg:block lg:mt-4">{globalData.navigation.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/contacts')} className="grid gap-4 mt-4 md:grid-cols-3 lg:block lg:mt-6 lg:space-y-4"> {/* 表单 */} <input name="name" placeholder={`${globalData.navigation.placeholderOfName} *`} required className="customized-form"/> <input name="wechatOrQQ" placeholder={`${globalData.navigation.placeholderOfWechatOrQQ} *`} required className="customized-form" /> <input name="tel" placeholder={globalData.navigation.placeholderOftel} className="customized-form" /> <textarea name="msg" placeholder={`${globalData.navigation.placeholderOfMessage} *`} required className="customized-form min-h-24 md:col-span-3"></textarea> <button type="submit" className="customized-form py-3 font-medium text-center uppercase text-primary cursor-pointer md:cal-span-3">{globalData.navigation.submitButtonText}</button> </form> </div> <div className={`hidden lg:block lg:w-full lg:transition-transform lg:duration-500 lg:ease-in ${navStatus ? 'lg:translate-y-0' : 'lg:translate-y-8'}`}> {/* 插图容器,仅桌面端显示 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${globalData.navigation.image.url}`} alt={`${globalData.navigation.image.alternativeText}${globalData.globalAddOn}`} width={globalData.navigation.image.width} height={globalData.navigation.image.height} className="size-full rounded-[2.5rem] object-cover" /> <span className="customized-close-button"></span> {/* 关闭按钮,仅桌面端显示 */} </div> </div> <ul className="grid grid-cols-5 gap-4 mt-6 pb-8 lg:hidden"> {/* 社媒 */} <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-website block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-bilibili block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-wechat block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-email block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer"> <span className="icon icon-nav-contact-more block mx-auto"></span> </a> </li> </ul> </div> </div> </div> <span className={`absolute block w-full h-12 left-0 bottom-18 bg-linear-to-b from-slate-50/0 via-50% via-slate-50 to-slate-50 duration-300 ease-in lg:hidden ${navStatus ? 'opacity-100' : 'max-lg:opacity-0'}`}></span> {/* 渐变遮罩 */} <span className={`absolute block w-30 h-1.5 rounded-full left-[calc(50%-3.75rem)] bottom-20 bg-secondary/33 duration-300 ease-in lg:hidden ${navStatus ? 'opacity-100' : 'max-lg:opacity-0'}`} onClick={() => setNavStatus(false)}></span> {/* 指示器 */} </nav> ) }
@/components/global/Nav2.js
// Import React hooks import { useState } from 'react' // Import Next components import Link from 'next/link' import Image from 'next/image' import { useRouter } from 'next/router' // Import utilities import { handleFormSubmit } from '@/utils/handleFormSubmit' export default function Nav2({ globalData }) { const router = useRouter(); const [navStatus, setNavStatus] = useState(false); return ( <nav className="group z-50 fixed w-full top-0 px-6 pt-6 pb-20 overflow-hidden transition-[max-height] duration-1000 ease-in-out max-h-20 has-peer-checked:max-h-200 md:px-9 lg:has-peer-checked:overflow-visible lg:has-peer-not-checked:animate-[reveal-clip_1s_ease-in]"> {/* 导航条 */} <input name="navStatus" type="checkbox" className="peer appearance-none z-10 absolute block w-[calc(100%-3rem)] h-12 top-6 left-6 md:w-[calc(100%-4rem)] md:left-9 lg:hidden" checked={navStatus} onChange={(e) => setNavStatus(e.target.checked)} /> {/* checkbox 作为 peer,通过 :checked 状态控制同级元素样式 */} <span className="absolute block w-full h-[calc(100%-4rem)] inset-0 max-lg:rounded-b-3xl duration-300 ease-in max-lg:group-has-peer-checked:bg-slate-50 max-lg:group-has-peer-checked:shadow-xl max-lg:group-has-peer-checked:shadow-primary/12 lg:h-screen lg:opacity-0 lg:backdrop-blur-lg lg:group-has-peer-checked:opacity-100"></span> {/* 菜单背景 */} <div className="absolute w-[calc(100%-3rem)] max-w-366 h-12 left-6 md:w-[calc(100%-4rem)] md:left-9 lg:h-18 2xl:left-[calc(50%-45.75rem)]"> {/* 导航条背景 */} <span className="customized-shadow opacity-100 transition-opacity duration-300 ease-in max-lg:group-has-peer-checked:opacity-0"></span> {/* 下层阴影 */} <span className="absolute block size-full rounded-full bg-slate-50 max-lg:group-has-peer-checked:bg-primary/5 transition duration-300 ease-in"></span> {/* 上层着色 */} </div> <div className="relative justify-between items-center max-w-366 max-lg:max-h-0 transition-[max-height] duration-1000 ease-in max-lg:group-has-peer-checked:max-h-200 lg:flex lg:px-9 2xl:left-[calc(50%-45.75rem)]"> {/* 内容容器 */} <div className="relative h-12 flex justify-center items-center lg:h-18"> {/* Logo 容器 */} <Image src="/logo/logo.svg" alt={`logo${globalData.globalAddOn}`} width={128} height={128} className="relative h-6 w-auto aspect-square lg:h-8" /> </div> <ul className="grid grid-cols-2 max-lg:gap-4 max-lg:mt-4 max-lg:opacity-0 transition-opacity duration-300 ease-in max-lg:group-has-peer-checked:opacity-100 md:grid-cols-4 lg:flex lg:justify-center lg:mt-0"> {/* 导航菜单 */} <li> <Link href="/" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className={`icon icon-nav-list-home${router.pathname == '/' ? '-filled' : ''} mr-1.5`}></span> Home </Link> </li> <li> <Link href="/course" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className={`icon icon-nav-list-course${router.pathname == '/course' ? '-filled' : ''} mr-1.5`}></span> Course </Link> </li> <li> <Link href="/blog" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className={`icon icon-nav-list-blog${router.pathname.includes('/blog') || router.pathname.includes('/category') ? '-filled' : ''} mr-1.5`}></span> blog </Link> </li> <li> <Link href="/about" className="customized-nav-list" onClick={() => setNavStatus(false)}> <span className={`icon icon-nav-list-about${router.pathname == '/about' ? '-filled' : ''} mr-1.5`}></span> About </Link> </li> </ul> <div className="relative hidden lg:block"> {/* 联络按钮容器,仅桌面端显示 */} <span className="customized-shadow"></span> {/* 自定义阴影效果 */} <button className="customized-contact-button relative" onClick={() => setNavStatus(!navStatus)}>Contact</button> </div> <div className="relative max-lg:mt-6 opacity-0 transition-opacity duration-300 ease-in group-has-peer-checked:opacity-100 lg:absolute lg:w-240 lg:left-[calc(50%-30rem)] lg:top-39"> {/* 联络与社媒 */} <div className="max-h-[calc(100vh-18rem)] h-[calc(100vh-18rem)] overflow-x-hidden overflow-y-auto"> {/* 滚动容器 */} <div className="lg:flex lg:h-full lg:p-3"> {/* 联络弹窗 */} <span className="hidden absolute size-full inset-0 rounded-[3rem] ring-white ring-2 backdrop-blur-2xl lg:block"> {/* 联络弹窗背景,仅桌面端显示 */} <span className="absolute block size-full top-0 rounded-[3rem] inset-shadow-[.1875rem_.375rem_1.5rem_white]"></span> <svg xmlns="http://www.w3.org/2000/svg" width="52.305" height="52.295" viewBox="-24 -24 96 96"> <path id="high_light_corner" d="M.545,52.295h0C.417,50.877.354,49.432.354,48a47.96,47.96,0,0,1,48-48c1.435,0,2.884.064,4.305.19A72.114,72.114,0,0,0,.545,52.295Z" className="absolute inset-6 fill-white opacity-100 blur-md"/> </svg> </span> <div className="shrink-0 lg:flex lg:flex-col lg:w-102 lg:px-12 lg:py-15 lg:mr-3 lg:rounded-[2.5rem] lg:bg-white lg:overflow-scroll lg:transition-transform lg:duration-500 lg:ease-in lg:-translate-y-9 lg:group-has-peer-checked:translate-y-0"> {/* 联络内容 */} <div> {/* 标题与段落 */} <Image src="/logo/logo.svg" alt={`logo${globalData.globalAddOn}`} width={128} height={128} className="relative hidden w-15 aspect-square lg:block" /> <h3 className="font-medium text-center lg:mt-6 lg:text-left lg:text-2xl">{globalData.navigation.heading}</h3> <p className="hidden lg:block lg:mt-4">{globalData.navigation.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/contacts')} className="grid gap-4 mt-4 lg:block lg:mt-6 lg:space-y-4"> {/* 表单 */} <input name="name" placeholder={`${globalData.navigation.placeholderOfName} *`} required className="customized-form"/> <input name="wechatOrQQ" placeholder={`${globalData.navigation.placeholderOfWechatOrQQ} *`} required className="customized-form" /> <input name="tel" placeholder={globalData.navigation.placeholderOftel} className="customized-form" /> <textarea name="msg" placeholder={`${globalData.navigation.placeholderOfMessage} *`} required className="customized-form min-h-24 md:col-span-3"></textarea> <button type="submit" className="customized-form py-3 font-medium text-center uppercase text-slate-600 cursor-pointer md:col-span-3">{globalData.navigation.submitButtonText}</button> </form> </div> <div className="hidden lg:block lg:w-full lg:transition-transform lg:duration-500 lg:ease-in lg:translate-y-8 group-has-peer-checked:lg:translate-y-0"> {/* 插图容器,仅桌面端显示 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${globalData.navigation.image.url}`} alt={`${globalData.navigation.image.alternativeText}${globalData.globalAddOn}`} width={globalData.navigation.image.width} height={globalData.navigation.image.height} className="size-full rounded-[2.5rem] object-cover"/> <span className="customized-close-button" onClick={() => setNavStatus(false)}></span> {/* 关闭按钮,仅桌面端显示 */} </div> </div> <ul className="grid grid-cols-5 gap-4 mt-6 pb-8 lg:hidden"> {/* 社媒 */} <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-website block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-bilibili block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-wechat block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer" className=""> <span className="icon icon-nav-contact-email block mx-auto"></span> </a> </li> <li className="customized-social"> <a href="" target="_blank" rel="noopener noreferrer"> <span className="icon icon-nav-contact-more block mx-auto"></span> </a> </li> </ul> </div> </div> </div> <span className="absolute block w-full h-12 left-0 bottom-18 bg-linear-to-b from-slate-50/0 via-50% via-slate-50 to-slate-50 opacity-0 transition-opacity duration-300 ease-in group-has-peer-checked:opacity-100 lg:hidden"></span> {/* 渐变遮罩 */} <span className="absolute block w-30 h-1.5 rounded-full left-[calc(50%-3.75rem)] bottom-20 bg-secondary/33 opacity-0 transition-opacity duration-300 ease-in group-has-peer-checked:opacity-100 lg:hidden" onClick={() => setNavStatus(false)}></span> {/* 指示器 */} </nav> ) }

下表是上述代码中的关键优化,请对比检阅掌握其知识点:

优化要点优化前优化后说明
调整次序mt-2 md:mt-4 w-full block opacity-50 flex rotate-45 absoluteabsolute flex w-full mt-2 opacity-50 rotate-45 md:mt-4提升易读性,筛选冲突类名(block 被 flex 覆盖)
限定范围group-has-peer-checked:bg-slate-50 ... lg:group-has-peer-checked:bg-none lg:shadow-none ...max-lg:group-has-peer-checked:bg-slate-50 max-lg:group-has-peer-checked:shadow-xl ...通过缩小范围减少类名数量
提取重复样式组合<div class="max-w-8xl mx-auto px-6 md:px-9 lg:px-18"></div> 重复使用在 globals.css 用 @apply 定义为 .customized-container便于统一修改全局复用样式,但需避免过度抽象
向下控制优先<ul><li class="mt-2 border-t"></li><li class="mt-2 border-t"></li></ul><ul class="space-y-2 divide-y"><li></li><li></li></ul>用父级控制子元素布局,减少重复类名
变形代替位移left-2 top-6 duration-500 checked:top-0translate-x-2 translate-y-6 duration-500 checked:translate-y-0position 会触发重排重绘, transform 不影响文本流且触发 GPU 加速,性能更好
限定动画类型translate-x-4 px-2 duration-300 hover:translate-x-0 md:px-4translate-x-4 px-2 transition-transform duration-300 hover:translate-x-0 md:px-4限定过渡属性,避免 duration-* 默认的 transition-all 计算开销

详见 Tailwind CSS 开发指南 - 代码优化

最后,别忘了回到 Layout 中取消 <main>SubscriptionFooter 的注释:

@/components/Layout.js
import { Sen } from 'next/font/google' import Background from './global/Background' import Nav from '@/components/global/Nav' import Nav2 from './global/Nav2' import Subscription from './global/Subscription' import Footer from '@/components/global/Footer' const sen = Sen({ subsets: ['latin'] // 设置一个 subsets 参数 }) export default function Layout({ globalData, children }) { return ( <div className={`${sen.className} relative`}> <Background globalData={globalData}/> {/* 在这里传入数据,下同 */} {/* <Nav globalData={globalData}/> */} <Nav2 globalData={globalData}/> <main>{ children }</main> <Subscription globalData={globalData}/> <Footer globalData={globalData} /> </div> ) }

至此,全局组件的开发已经完成,不过 Nav 还有未随着页面内容更新 List 状态等多个问题,这些涉及到大量 React hooks 的知识点,因此都放到下一个章节(Strapi + Next.JS + JSX:博客文章搜索)里讲,接下来完成页面开发。

4.4 Tailwind CSS:主要页面布局实战

本小节我们来开发四个主要页面。

接下来的每个组件我都会在末尾处总结做了哪些改动,以方便对照理解。

4.4.1 主页(Home)

在本页面中,我们需要完成五个页面专属组件 Introduction.jsFrameworks.jsComparisons.jsGuideChapters.jsPlayground.js,以及一个共享组件 About.js

首先我们来看一下主页的代码:

@/pages/index.js
// Import libraries import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Introduction from '@/components/home/Introduction' import Comparisons from '@/components/home/Comparisons' import GuideChapters from '@/components/home/GuideChapters' import Playground from '@/components/home/Playground' import About from '@/components/shared/About' export default function Home({ globalData, data }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo}/> <Introduction globalData={globalData} data={data.introduction} frameworksData={data.frameworks}/> <Comparisons globalData={globalData} comparisons={data.comparisons} comparisonInformations={data.comparisonInformations}/> <GuideChapters guideChapters={data.guideChapters} guideChapterContents={data.guideChapterContents}/> <Playground globalData={globalData} playground={data.playground} playgroundContents={data.playgroundContents}/> <About globalData={globalData} data={globalData.aboutUs}/> </> ) } export async function getStaticProps() { const response = await fetcher(`home?populate[0]=introduction&populate[1]=frameworks&populate[2]=comparisons&populate[3]=comparisonInformations.contents&populate[4]=comparisonInformations.contents.detailList&populate[5]=comparisonInformations.contents.logo&populate[6]=guideChapters&populate[7]=guideChapterContents&populate[8]=guideChapters.image&populate[9]=playground&populate[10]=playgroundContents&populate[11]=seo`) return { props: { data: response.data } } }

其中 CustomizedHead 仅包含页面 <head> 中的信息,因此无需做样式修缮;按照页面顺序,我们先来处理 Introduction.js。不过首先,我们需要定义一些新内容到 globals.css 中去:

@/styles/globals.css
@layer components { ... .customized-link-button { @apply inline-flex items-center h-13 pl-6 rounded-full bg-slate-50/50 text-secondary whitespace-nowrap shadow-md shadow-slate-300/67 backdrop-blur-sm backdrop-brightness-105 transition duration-300 ease-out; span { @apply relative flex justify-center items-center size-13 ml-4 rounded-full bg-linear-to-br from-white from-100% via-white/25 via-50% to-white to-100% shadow-sm shadow-slate-300/67; @variant before { @apply absolute size-12 inset-0.5 rounded-full bg-slate-200/50; } @variant after { @apply relative block size-5 bg-[url('/image/icons.svg')] bg-position-[0_-27.375rem] bg-no-repeat; } } @variant hover { @apply shadow-lg -translate-y-1; } } } @layer utilities { ... /* Keyframes */ @keyframes reveal-clip { 0%, 99% { overflow: visible; } 100% { overflow: hidden; } } @keyframes toast-popup-from-top { 0% { visibility: hidden; opacity: 0; transform: translateY(0); } 1% { visibility: visible; opacity: 0; transform: translateY(0); } 10% { visibility: visible; opacity: 1; transform: translateY(1rem); } 90% { visibility: visible; opacity: 1; transform: translateY(1rem); } 99% { visibility: visible; opacity: 0; transform: translateY(0); } 100% { visibility: hidden; opacity: 0; transform: translateY(0); } } }

然后编辑 IntroductionDecoration.js

@/components/home/IntroductionDecoration.js
// Import React components import { useEffect, useState } from 'react' // Import Next components import Image from 'next/image' export default function IntroductionDecoration({ globalData }) { const [elementScrolled, setElementScrolled] = useState(false); useEffect(() => { const onScroll = () => { const y = window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0; setElementScrolled(y > 480); }; onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, []); useEffect(() => { if (!elementScrolled) return; document.querySelectorAll('.rd-card-box input').forEach(el => { if (el.checked) { el.checked = false; el.dispatchEvent(new Event('change', { bubbles: true })); } if (el instanceof HTMLInputElement) el.blur(); }); }, [elementScrolled]); return ( <div className="relative max-xl:w-screen max-xl:h-120 -ml-6 max-xl:-mt-24 max-xl:-mb-36 max-xl:overflow-x-hidden hide-scrollbar max-xl:duration-300 ease-out has-peer-checked:h-160 has-peer-checked:-mt-36 has-peer-checked:-mb-12 md:-ml-9 lg:-ml-18"> <div className="rd-card-box absolute flex justify-center z-10 w-40 h-40 inset-[calc(50%-5rem)] py-8 mx-auto scale-150 transition-transform duration-300 ease has-peer-checked:scale-100 xl:scale-200 xl:has-peer-checked:scale-150"> <div className="group relative size-24 transition-transform rotate-[.75_1_.75_45deg] duration-500 ease perspective-midrange transform-3d *backface-visible has-checked:rotate-0"> <input type="checkbox" name="cardBox" className="peer appearance-none absolute block size-[135%] z-50 rounded-lg translate-z-25 -translate-x-16 translate-y-10 opacity-0 duration-500 checked:border-dotted checked:border-2 checked:border-gray-500 checked:-translate-x-4 checked:-translate-y-18 checked:opacity-25 xl:checked:translate-y-0"/> <span className="absolute flex justify-center items-center size-1/5 inset-1/2 -ml-2.5 my-1 opacity-0 transition-opacity duration-500 ease-out before:absolute before:w-full before:h-0.5 before:rounded before:bg-slate-400 before:rotate-45 after:absolute after:w-full after:h-0.5 after:rounded after:bg-slate-400 after:-rotate-45 peer-checked:opacity-100 max-xl:peer-checked:-translate-y-18 xl:-ml-2"></span> <div className="absolute size-full ring-white/25 ring-2 rounded-lg bg-linear-65 from-slate-100 via-slate-200 to-slate-300 transition duration-500 ease translate-z-12 rotate-y-0 peer-not-checked:peer-hover:translate-z-14 peer-checked:w-32 peer-checked:h-40 peer-checked:shadow-lg peer-checked:shadow-slate-500/16 peer-checked:-translate-x-[115px] peer-checked:translate-y-[120px] peer-checked:-rotate-z-30 peer-checked:rotate-x-0 peer-checked:rotate-y-0 peer-checked:duration-1000 peer-checked:ease-in-out peer-checked:hover:z-50 peer-checked:hover:translate-y-[80px] peer-checked:hover:duration-500 has-[.peer\/touch:focus]:z-50 has-[.peer\/touch:focus]:translate-y-[80px] has-[.peer\/touch:focus]:duration-500 md:peer-checked:-translate-x-[200px] xl:peer-checked:translate-x-[calc(-48vw+15rem)] xl:peer-checked:translate-y-[calc(50%+14rem)] xl:peer-checked:hover:translate-y-[calc(50%+11rem)] 2xl:peer-checked:translate-x-[calc(-33rem)]"> <input type="button" name="card" defaultValue="" className="peer/touch appearance-none absolute z-10 block size-0 top-0 opacity-0 cursor-wait transition duration-1000 ease-out group-has-peer-checked:size-full xl:hidden"/> <a href="" target="_blank" className="peer/card absolute z-10 block size-0 top-0 duration-100 ease-out peer-focus/touch:size-full xl:group-has-peer-checked:size-full" ></a> <span className="absolute block w-full -top-10 text-center text-xs opacity-0 duration-300 ease-out peer-focus/touch:opacity-100 peer-hover/card:opacity-100 xl:text-xs">前端入门推荐<br />8小时快速建站</span> <Image src="/image/home/home_decoration_card_square_1.png" alt={`Decoration card - 1${globalData.globalAddOn}`} width={216} height={258} className="absolute size-full aspect-square opacity-100 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-0" /> <Image src="/image/home/home_decoration_card_1.png" alt={`Decoration card - 1${globalData.globalAddOn}`} width={216} height={258} className="absolute h-full opacity-0 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-100" /> </div> <div className="absolute size-full ring-white/25 ring-2 rounded-lg bg-linear-65 from-slate-100 via-slate-200 to-slate-300 transition duration-500 ease -translate-x-12 -rotate-y-90 peer-not-checked:peer-hover:-translate-x-14 peer-checked:w-32 peer-checked:h-40 peer-checked:shadow-lg peer-checked:shadow-slate-500/16 peer-checked:-translate-x-[75px] peer-checked:translate-y-[80px] peer-checked:-rotate-z-18 peer-checked:rotate-x-0 peer-checked:rotate-y-0 peer-checked:duration-1000 peer-checked:ease-in-out peer-checked:hover:z-50 peer-checked:hover:translate-y-[60px] peer-checked:hover:duration-500 [has(input:focus)]:z-50 [has(input:focus)]:translate-y-[60px] [has(input:focus)]:duration-500 has-[.peer\/touch:focus]:z-50 has-[.peer\/touch:focus]:translate-y-[60px] has-[.peer\/touch:focus]:duration-500 md:peer-checked:-translate-x-[120px] xl:peer-checked:translate-x-[calc(-43vw+15rem)] xl:peer-checked:translate-y-[calc(50%+12rem)] xl:peer-checked:hover:translate-y-[calc(50%+10rem)] 2xl:peer-checked:translate-x-[calc(-28rem)]"> <input type="button" name="card" defaultValue="" className="peer/touch appearance-none absolute z-10 block size-0 top-0 opacity-0 cursor-wait transition duration-1000 ease-out group-has-peer-checked:size-full xl:hidden"/> <a href="" target="_blank" className="peer/card absolute z-10 block size-0 top-0 duration-100 ease-out peer-focus/touch:size-full xl:group-has-peer-checked:size-full" ></a> <span className="absolute block w-full -top-10 text-center text-xs opacity-0 duration-300 ease-out peer-focus/touch:opacity-100 peer-hover/card:opacity-100 xl:text-xs">Strapi + Next.JS + Tailwind CSS 项目实战</span> <Image src="/image/home/home_decoration_card_square_2.png" alt={`Decoration card - 2${globalData.globalAddOn}`} width={216} height={258} className="absolute size-full aspect-square opacity-100 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-0" /> <Image src="/image/home/home_decoration_card_2.png" alt={`Decoration card - 2${globalData.globalAddOn}`} width={216} height={258} className="absolute h-full opacity-0 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-100" /> </div> <div className="absolute size-full ring-white/25 ring-2 rounded-lg bg-linear-65 from-slate-100 via-slate-200 to-slate-300 transition duration-500 ease translate-y-12 -rotate-x-90 peer-not-checked:peer-hover:translate-y-14 peer-checked:w-32 peer-checked:h-40 peer-checked:shadow-lg peer-checked:shadow-slate-500/16 peer-checked:-translate-x-[35px] peer-checked:translate-y-[60px] peer-checked:-rotate-z-6 peer-checked:rotate-x-0 peer-checked:rotate-y-0 peer-checked:duration-1000 peer-checked:ease-in-out peer-checked:hover:z-50 peer-checked:hover:translate-y-[40px] peer-checked:hover:duration-500 has-[.peer\/touch:focus]:z-50 has-[.peer\/touch:focus]:translate-y-[40px] has-[.peer\/touch:focus]:duration-500 md:peer-checked:-translate-x-[40px] xl:peer-checked:translate-x-[calc(-38vw+15rem)] xl:peer-checked:translate-y-[calc(50%+11rem)] xl:peer-checked:hover:translate-y-[calc(50%+9.5rem)] 2xl:peer-checked:translate-x-[calc(-23rem)]"> <input type="button" name="card" defaultValue="" className="peer/touch appearance-none absolute z-10 block size-0 top-0 opacity-0 cursor-wait transition duration-1000 ease-out group-has-peer-checked:size-full xl:hidden"/> <a href="" target="_blank" className="peer/card absolute z-10 block size-0 top-0 duration-100 ease-out peer-focus/touch:size-full xl:group-has-peer-checked:size-full" ></a> <span className="absolute block w-full -top-10 text-center text-xs opacity-0 duration-300 ease-out peer-focus/touch:opacity-100 peer-hover/card:opacity-100 xl:text-xs">前端额外章节<br />知识与指南收录</span> <Image src="/image/home/home_decoration_card_square_3.png" alt={`Decoration card - 3${globalData.globalAddOn}`} width={216} height={258} className="absolute size-full aspect-square opacity-100 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-0" /> <Image src="/image/home/home_decoration_card_3.png" alt={`Decoration card - 3${globalData.globalAddOn}`} width={216} height={258} className="absolute h-full opacity-0 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-100" /> </div> <div className="absolute size-full ring-white/25 ring-2 rounded-lg bg-linear-65 from-slate-100 via-slate-200 to-slate-300 transition duration-500 ease -translate-z-12 rotate-y-180 peer-not-checked:peer-hover:-translate-z-14 peer-checked:w-32 peer-checked:h-40 peer-checked:shadow-lg peer-checked:shadow-slate-500/16 peer-checked:translate-x-[5px] peer-checked:translate-y-[60px] peer-checked:rotate-z-6 peer-checked:rotate-x-0 peer-checked:rotate-y-0 peer-checked:duration-1000 peer-checked:ease-in-out peer-checked:hover:z-50 peer-checked:hover:translate-y-[40px] peer-checked:hover:duration-500 has-[.peer\/touch:focus]:z-50 max-xl:has-[.peer\/touch:focus]:translate-y-[40px] has-[.peer\/touch:focus]:duration-500 md:peer-checked:translate-x-[40px] xl:peer-checked:translate-x-[calc(-33vw+15rem)] xl:peer-checked:translate-y-[calc(50%+11rem)] xl:peer-checked:hover:translate-y-[calc(50%+9.5rem)] 2xl:peer-checked:translate-x-[calc(-18rem)]"> <input type="button" name="card" defaultValue="" className="rd-coming-soon peer/touch appearance-none absolute z-10 block size-0 top-0 opacity-0 cursor-wait transition duration-1000 ease-out group-has-peer-checked:size-full"/> {/* <a href="" target="_blank" className="peer/card absolute z-10 block size-0 top-0 duration-100 ease-out peer-focus/touch:size-full xl:group-has-peer-checked:size-full"></a> */} <span className="absolute block w-full -top-10 text-center text-xs opacity-0 duration-300 ease-out peer-focus/touch:opacity-100 peer-hover/touch:opacity-100 xl:text-xs">Shopify Theme 指南<br />(正在制作,敬请期待)</span> <Image src="/image/home/home_decoration_card_square_4.png" alt={`Decoration card - 4${globalData.globalAddOn}`} width={216} height={258} className="absolute size-full aspect-square opacity-100 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-0" /> <Image src="/image/home/home_decoration_card_4.png" alt={`Decoration card - 4${globalData.globalAddOn}`} width={216} height={258} className="absolute h-full opacity-0 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-100" /> </div> <div className="absolute size-full ring-white/25 ring-2 rounded-lg bg-linear-65 from-slate-100 via-slate-200 to-slate-300 transition duration-500 ease -translate-y-12 rotate-x-90 peer-not-checked:peer-hover:-translate-y-14 peer-checked:w-32 peer-checked:h-40 peer-checked:shadow-lg peer-checked:shadow-slate-500/16 peer-checked:translate-x-[45px] peer-checked:translate-y-[80px] peer-checked:rotate-z-18 peer-checked:rotate-x-0 peer-checked:rotate-y-0 peer-checked:duration-1000 peer-checked:ease-in-out peer-checked:hover:z-50 peer-checked:hover:translate-y-[60px] peer-checked:hover:duration-500 has-[.peer\/touch:focus]:z-50 has-[.peer\/touch:focus]:translate-y-[60px] has-[.peer\/touch:focus]:duration-500 md:peer-checked:translate-x-[120px] xl:peer-checked:translate-x-[calc(-28vw+15rem)] xl:peer-checked:translate-y-[calc(50%+12rem)] xl:peer-checked:hover:translate-y-[calc(50%+10rem)] 2xl:peer-checked:translate-x-[calc(-13rem)]"> <input type="button" name="card" defaultValue="" className="peer/touch appearance-none absolute z-10 block size-0 top-0 opacity-0 cursor-wait transition duration-1000 ease-out group-has-peer-checked:size-full xl:hidden"/> <a href="" target="_blank" className="peer/card absolute z-10 block size-0 top-0 duration-100 ease-out peer-focus/touch:size-full xl:group-has-peer-checked:size-full" ></a> <span className="absolute block w-full -top-10 text-center text-xs opacity-0 duration-300 ease-out peer-focus/touch:opacity-100 peer-hover/card:opacity-100 xl:text-xs">视觉识别系统<br />品牌调研 + Logo 设计</span> <Image src="/image/home/home_decoration_card_square_5.png" alt={`Decoration card - 5${globalData.globalAddOn}`} width={216} height={258} className="absolute size-full aspect-square opacity-100 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-0" /> <Image src="/image/home/home_decoration_card_5.png" alt={`Decoration card - 5${globalData.globalAddOn}`} width={216} height={258} className="absolute h-full opacity-0 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-100" /> </div> <div className="absolute size-full ring-white/25 ring-2 rounded-lg bg-linear-65 from-slate-100 via-slate-200 to-slate-300 transition duration-500 ease translate-x-12 rotate-y-90 peer-not-checked:peer-hover:translate-x-14 peer-checked:w-32 peer-checked:h-40 peer-checked:shadow-lg peer-checked:shadow-slate-500/16 peer-checked:translate-x-[85px] peer-checked:translate-y-[120px] peer-checked:rotate-z-30 peer-checked:rotate-x-0 peer-checked:rotate-y-0 peer-checked:duration-1000 peer-checked:ease-in-out peer-checked:hover:z-50 peer-checked:hover:translate-y-[80px] peer-checked:hover:duration-500 has-[.peer\/touch:focus]:z-50 has-[.peer\/touch:focus]:translate-y-[80px] has-[.peer\/touch:focus]:duration-500 md:peer-checked:translate-x-[200px] xl:peer-checked:translate-x-[calc(-23vw+15rem)] xl:peer-checked:translate-y-[calc(50%+14rem)] xl:peer-checked:hover:translate-y-[calc(50%+11rem)] 2xl:peer-checked:translate-x-[calc(-8rem)]"> <input type="button" name="card" defaultValue="" className="peer/touch appearance-none absolute z-10 block size-0 top-0 opacity-0 cursor-wait transition duration-1000 ease-out group-has-peer-checked:size-full xl:hidden"/> <a href="" target="_blank" className="peer/card absolute z-10 block size-0 top-0 duration-100 ease-out peer-focus/touch:size-full xl:group-has-peer-checked:size-full" ></a> <span className="absolute block w-full -top-10 text-center text-xs opacity-0 duration-300 ease-out peer-focus/touch:opacity-100 peer-hover/card:opacity-100 xl:text-xs">Rainy Design Studio<br />推广合作 / 品牌服务</span> <Image src="/image/home/home_decoration_card_square_6.png" alt={`Decoration card - 6${globalData.globalAddOn}`} width={216} height={258} className="absolute size-full aspect-square opacity-100 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-0" /> <Image src="/image/home/home_decoration_card_6.png" alt={`Decoration card - 6${globalData.globalAddOn}`} width={216} height={258} className="absolute h-full opacity-0 object-top object-cover transition-opacity duration-500 ease-out group-has-peer-checked:opacity-100" /> </div> </div> </div> <div className="flex invisible fixed z-50 items-center left-[calc(50%-3.625rem)] top-18 px-6 py-4 -ml-6 rounded-xl ring-2 ring-slate-200 bg-slate-50 shadow-xl shadow-slate-500/33 opacity-0 transition duration-300 ease-out [div:has(.peer\/touch:not(.rd-coming-soon):focus)+&]:animate-[toast-popup-from-top_3s_ease-in-out] lg:top-36"> <span className="inline-flex justify-center items-center size-4 mr-1.5 rounded-full bg-emerald-400 before:block before:w-1 before:h-0.5 before:rounded before:-mr-px before:bg-white before:rotate-45 after:block after:w-2 after:h-0.5 after:rounded after:-ml-px after:bg-white after:-rotate-45"></span> <p>再次点按访问</p> </div> <div className="flex invisible fixed z-50 items-center left-[calc(50%-3.125rem)] top-18 px-6 py-4 -ml-6 rounded-xl ring-2 ring-slate-200 bg-slate-50 shadow-xl shadow-slate-500/33 opacity-0 transition duration-300 ease-out [div:has(.rd-coming-soon:focus)~&]:animate-[toast-popup-from-top_3s_ease-in-out] lg:top-36"> <span className="inline-flex justify-center items-center size-4 mr-1.5 rounded-full bg-emerald-400 before:block before:w-1 before:h-0.5 before:rounded before:-mr-px before:bg-white before:rotate-45 after:block after:w-2 after:h-0.5 after:rounded after:-ml-px after:bg-white after:-rotate-45"></span> <p>敬请期待!</p> </div> </div> ) }
小提示:这里 Tailwind CSS Intellisence 会提示可以将 *-[*px] 转换为四分之一的 spacing 尺寸,不过由于内容都是写死的所以可以不用管。

为了实现 pure CSS animation 而不依赖任何 JS-DOM 操作,这个组件的逻辑便变得十分复杂,有空开个专题去讲解如何实现,现在只管复制粘贴就好。最后修改 Introduction.js

@/components/home/Introduction.js
// Import Next components import Link from 'next/link' // Import Libraries import Markdown from '@/lib/markdown' // Import components import IntroductionDecoration from './IntroductionDecoration' import Frameworks from './Frameworks' export default function Introduction({ globalData, data, frameworksData }) { return ( <div className="customized-container mt-25 lg:pt-14 lg:mt-24 2xl:pt-24"> {/* 外部容器 */} <div className="justify-between items-center xl:flex"> {/* 内部容器 */} <div className="xl:max-w-160 xl:mr-12"> {/* 左半部分 */} <h2 className="text-3xl lg:text-5xl">{data.heading}</h2> <div className="mt-4"> {/* 富文本容器 */} <Markdown data={data.description} /> </div> <div className="relative pt-3 px-6 pb-4 mt-4 rounded-lg font-mono text-sm bg-white shadow-md shadow-slate-300/67 before:absolute before:size-2 before:left-5 before:-top-4 before:border-8 before:border-t-transparent before:border-x-transparent before:border-white before:scale-x-75"> {/* 装饰背景框 */} <span className="absolute block size-[calc(100%-.25rem)] left-0.5 top-0.5 rounded-md bg-slate-50 before:absolute before:size-2 before:left-4.5 before:-top-3.75 before:border-8 before:border-t-transparent before:border-x-transparent before:border-slate-50 before:scale-x-75"></span> <span className="relative float-left text-slate-400">{data.codeblockLanguage}</span> <span className="relative float-right text-slate-400">{data.codeblockFile}</span> <div className="relative mt-10 overflow-x-scroll hide-scrollbar"> <Markdown data={data.codeblockContent} /> </div> </div> <Link href={data.buttonLink} {...(data.openInNewTab && { target: '_blank' })} className="customized-link-button mt-4"> {data.buttonText} <span></span> </Link> {/* 条件判断语句,如果 data.openInNewTab 为真才显示 target 参数与对应属性 */} </div> <div className="relative w-full xl:w-120"> {/* 右半部分 */} {data.customizedDecoration && data.customizedDecoration ? <Markdown data={data.decoration}/> : <IntroductionDecoration globalData={globalData}/>} {/* 自定义代码块或内置组件 */} </div> </div> <Frameworks data={frameworksData}/> </div> ) }

解释一下我们在 Introduction.js 做的 DOM 改动和 className 的定义都影响了什么:

  • 使用了统一适用于大尺寸、左右 padding 与 margin 的 .customized-container 作为外容器;
  • 内部容器在 xl: 断点下会因为 flex 自动变为横向排列,同时限定了子容器的断点尺寸;
  • 在代码块容器中使用边框透明特性巧妙搭建带三角形的背景框(详见 Tailwind CSS 开发指南 - 5.1 利用 border 画三角形 );
  • 将代码块容器中的 codeblockLanguagecodeblockFile 设置为左右浮动;
  • 新增了一个组件类名 .customized-link-button ,并添加了一个 <span> 作为图标容器以还原设计稿样式;
  • 在右侧容器中做判断:如果在 Strapi 中勾选了 customizedDecoration ,则使用自定义代码块,否则使用内置组件。

考虑到按小节进行学习,默认看到这里你已掌握了相关的知识点,之后雷同的知识点不做赘述。

然后是子组件 Frameworks.js,样式简单明了:

@/components/home/Frameworks.js
export default function Frameworks({ data }) { return ( <ul className="mt-12 lg:flex"> {/* 无序列表 */} {data && data.map((item, index) => ( <li key={index} className="px-6 pt-6 pb-9"> {/* 列表容器 */} <div className="absolute size-9 rounded-[.75rem_.25rem] bg-linear-to-br from-slate-50 to-slate-300 shadow-xs shadow-slate-400/33"> {/* 图标容器 */} <span className={`icon icon-framework-${item.icon} absolute`}></span> {/* 图标 */} </div> <p className="mt-1 ml-12 text-lg font-medium">{item.name}</p> <p className="ml-12">{item.description}</p> </li> ))} </ul> ) }

解释一下:

  • 断点 lg: 以上列表横向排列;
  • <li> 实现 padding 后,为两个 <p> 添加 margin-left 以自动撑开;
  • 图标容器实现圆角、背景的同时,由于 absolute 的默认坐标为计算父元素 padding 后的左上角,因此无须设置 inset-0

接下来是 Comparisons.js,这个也会相对麻烦一点,我们要先去 Strapi 后台把图片传好,再次打开 课程资源包 找到 Home 目录,在 Strapi 中创建对应的子目录 Comparison logos 并上传内容、填写 Alt 值即可(下文不赘述,仅提示上传图片到 Strapi),格式如下:

  • Website logo - Nuxt.JS
  • Website logo - Next.JS
  • Website logo - React
  • Layout logo - Less
  • Layout logo - Tailwind CSS
  • Layout logo - Bootstrap
  • CMS logo - Ghost
  • CMS logo - Strapi
  • CMS logo - Sanity

Tailwind CSS - 上传图片到 1.Home | Rainy Design Studio 雨点设计工作室

Tailwind CSS - 填写图片 Alt 值 - 2 | Rainy Design Studio 雨点设计工作室

然后看看代码:

@/styles/globals.css
... @layer components{ ... .customized-informations{ @apply space-y-4; h2 { @apply text-2xl font-medium; } } [class*="customized-icon-"] { @apply flex justify-center items-center size-6 rounded-full; @variant before { @apply absolute rounded bg-sky-800 rotate-45; } @variant after { @apply absolute rounded bg-sky-800 -rotate-45; } } .customized-icon-pro { @apply bg-sky-200; @variant before { @apply w-1.25 h-px -ml-1.75 mt-0.5 bg-sky-800 rotate-45; } @variant after { @apply w-2.25 h-px ml-0.5 bg-sky-800 -rotate-45; } } .customized-icon-con { @apply bg-slate-200; @variant before { @apply w-2.5 h-px bg-slate-400 rotate-45; } @variant after { @apply w-2.5 h-px bg-slate-400 -rotate-45; } } } ...
@/components/home/Comparisons.js
// Import Next Components import Link from 'next/link' import Image from 'next/image' export default function Comparisons({ globalData, comparisons, comparisonInformations }) { const checkedInput = { 0: "group-has-[ul>li:nth-child(1)>input:checked]:visible", 1: "group-has-[ul>li:nth-child(2)>input:checked]:visible", 2: "group-has-[ul>li:nth-child(3)>input:checked]:visible" } return ( <div className="customized-container-sm flex flex-col items-center space-y-9"> <div className="customized-informations max-w-3xl px-6 mt-30 text-center md:px-9"> {/* 上半部分介绍 */} <h2>{comparisons.heading}</h2> <p>{comparisons.description}</p> </div> <div className="group relative flex flex-col w-full space-y-9"> {/* 下方内容 */} <ul className="inline-flex mx-auto rounded-full bg-slate-50"> {/* Tab 栏 */} {/* 将三种 tab 名称与图标用 map 方法循环出来 */} {comparisonInformations && comparisonInformations.map((comparison, comparisonIndex) => ( <li key={comparisonIndex} className="relative flex items-center w-fit h-7 px-3 py-1 opacity-33 transition duration-300 has-peer-checked:opacity-100"> <input name="comparisonTab" type="radio" className="peer appearance-none absolute size-full inset-0 cursor-pointer" defaultChecked={!comparisonIndex}/> <span className={`icon icon-tab-${comparison.icon} mr-2`}></span> {comparison.name} </li> ))} </ul> <div className="relative h-110"> {/* 下半部容器 */} {comparisonInformations && comparisonInformations.map((comparison, comparisonIndex) => { {/* 分别渲染三组卡片内容 */} const order = { 'first': 0, 'second': 1, 'third': 2 }[comparison.recommendedContent] ?? comparisonIndex; {/* 根据 recommendedContent 字段映射到对应的索引值,如果未匹配则使用当前索引 */} return ( <div key={comparisonIndex} className={`absolute flex justify-stretch items-end invisible w-full pb-12 -mb-9 space-x-9 overflow-x-scroll snap-x snap-mandatory before:shrink-0 before:block before:w-[calc(50%-9.25rem)] after:shrink-0 after:w-[calc(50%-9.25rem)] lg:w-240 lg:left-[calc(50%-30rem)] lg:before:hidden lg:after:hidden ${checkedInput[comparisonIndex]}`}> {/* 卡片容器 */} {comparison.contents && comparison.contents.map((content, contentIndex) => ( <div key={contentIndex} className={`${order == contentIndex ? 'recommended ' : ''}shrink-0 relative flex flex-col items-start w-74 snap-center`}> {/* 第二层:卡片,如 order 等于 index, 则添加一个名为 recommended 的 className */} <span className="customized-shadow shadow-xl"></span> <div className={`absolute flex justify-end items-start w-60 h-12 right-0 pr-3 py-1.25 rounded-tr-3xl text-secondary ${order == contentIndex ? 'top-5 w-38 bg-sky-100' : 'top-2 bg-slate-200 before:left-20'}`}> {/* 评星与下载量容器,因为要被 Logo 图片容器压住所以文档流在上 */} <span className={`absolute block h-8 right-0 top-0 before:absolute before:size-4 before:h-4 before:bottom-0 before:bg-slate-50 after:absolute after:size-full after:inset-0 after:rounded-tr-3xl after:rounded-bl-2xl ${order == contentIndex ? 'w-38 after:bg-sky-100' : 'w-40 after:bg-slate-200'}`}></span> {/* 背景裁切左下角圆角 */} <div className="relative flex items-center px-1.5 space-x-1"> <span className="icon icon-statics-rating"></span> <p>{content.rating}</p> </div> <div className="relative flex items-center px-1.5 space-x-1"> <span className="icon icon-statics-download"></span> <p>{content.downloads}</p> </div> </div> <div className={`relative py-2 pb-1 rounded-t-3xl bg-slate-50 ${order == contentIndex ? 'px-2 -mb-2' : 'px-5 -mb-2'}`}> {/* Logo 图片容器 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${content.logo.url}`} alt={`${content.logo.alternativeText}${globalData.globalAddOn}`} width={content.logo.width} height={content.logo.height} className={order == contentIndex ? 'relative w-auto h-12' : ''}/> {/* 此处要注意是从 Strapi 获取文件信息,且 alternativeText 需拼接 globalData.globalAddOn */} </div> <div className="relative w-full p-4 rounded-tr-3xl rounded-b-3xl bg-slate-50"> {/* 卡片正文容器 */} {content && content.useDetailListAsContent ? <ul className="relative p-4 rounded-3xl bg-sky-50 space-y-3 overflow-hidden before:absolute before:size-48 before:-right-21 before:-top-13 before:bg-sky-200 before:opacity-50 before:blur-2xl after:absolute after:size-30 after:-left-6 after:-bottom-10 after:bg-sky-200 after:opacity-75 after:blur-2xl"> {/* 第三层:卡片正文,判断 useDetailListAsContent 的值,为真则启用 detailList */} {content.detailList.map((detail, detailIndex) => ( <li key={detailIndex} className="relative z-10 flex items-center space-x-2"> <span className={`${detail.advantage ? 'customized-icon-pro' : 'customized-icon-con'}`}></span> <p>{detail.description}</p> </li> ))} </ul> : <>{content.richtext}</>} {/* useDetailListAsContent 为假则启用 richtext */} </div> </div> ))} </div> ) })} </div> </div> <Link href={comparisons.buttonLink} {...(comparisons.openInNewTab && { target: '_blank' })} className="customized-link-button">{comparisons.buttonText}<span></span></Link> </div> ) }

解释一下:

  • 定义一个 customized-informations ,以便后续复用;
  • 目前没有用到任何 React Hooks,而是使用 Tailwind CSS 中的 group 添加到正文容器,再在子元素搭配 group-has-[] 选择器控制样式。这里涉及到了 Tailwind CSS 非完整匹配显式命名无法被正确识别的情况,所以我们可以将非大量复用的 className 显式映射到一个对象中去,然后直接调用;

    理论上来讲也可以注册到 globals.css 中的 @source inline 中去,但为了提升可读性和易维护性,建议在单个组件内部进行显式注册。

  • 采用了大量的 flex 以将内容折行到横向,以及配合 justify-*items-* 快速定位子元素大小与对齐方式,这是较为现代化的解决方案;
    • 如在卡片容器中使用了 flex justify-stretch items-end 使卡片横向撑开,纵向向下对齐,因为我们需要三张卡片宽度均等且 recommended 项高度稍高一点。
  • 使用了一个覆盖 tab 内容的 radio 并命名为 comparisonTab ,实现单选并配合选择器切换样式与兄弟元素内容,并通过 defaultChecked 属性控制初始选中首个 tab ;
  • 为卡片容器实现了横向滚动、阴影显示和滚动吸附功能,并添加了滚动容器的左右内边距:
    • 横向滚动时显示阴影:设置 overflow-x-scroll 样式会导致卡片下方的阴影一并被隐藏;我们可以通过添加下内边距与对应的高度解决这一问题,再用负下外边距将多出的高度减掉,参考 utilities 为 pb-12 -mb-9
    • 滚动吸附:为卡片容器(父元素)添加 snap-x snap-mandatory ,再为卡片(子元素)添加 snap-center 以居中定位;
    • 添加内边距:为卡片容器添加伪元素 before:* after:* 并利用 w-[calc(50%-...)] 自动计算宽度并撑开边距,以解决滚动到头尾卡片时父元素没有左右边距无法居中对齐的问题。
  • 为卡片容器内列表 <ul> 添加 overflow-hidden ,以及定义组件 .customized-icon-* 类控制单个 li > span,配合 :before, :after 伪类样式能轻松实现背景裁剪与简单的 pro 与 con 图标,无需图片或 svg。

此处我们还会注意到卡片并未自动水平居中到第二项,由于纯 HTML + CSS 无法动态计算并修改滚动容器的 scrollLeft 属性,我们这里建议用 React hook useEffect() 来实现偏移坐标的计算与修改。先前提到过,这部分内容都放到下一个章节(Strapi + Next.JS + JSX:博客文章搜索)里讲。

下一个是 GuideChapters.js,该组件考验我们对子元素拼接圆角以实现完全自适应的样式把控能力,并运用了双端交互逻辑技巧:

@/components/home/GuideChapters.js
// Import Next Components import Link from 'next/link' export default function GuideChapters({ guideChapters, guideChapterContents }) { return ( <div className="customized-container items-center mt-30 max-lg:space-y-9 lg:flex lg:space-x-15"> {/* 外部容器 */} <div className="justify-between items-center md:max-lg:flex lg:min-w-78"> {/* 左侧信息 */} <div className="customized-informations"> <h2>{guideChapters.heading}</h2> <p>{guideChapters.description}</p> </div> <Link href={guideChapters.buttonLink} {...(guideChapters.openInNewTab && { target: '_blank' })} className="customized-link-button mt-9 md:max-lg:mt-0 md:max-lg:ml-9"> {guideChapters.buttonText} <span></span> </Link> </div> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:max-w-240"> {/* 右侧章节栏 */} {guideChapterContents && guideChapterContents.map((item, index) => ( <div key={index} className="group relative w-full min-h-48 drop-shadow drop-shadow-sky-300/25 transition duration-300"> {/* 章节卡片 */} <input type="radio" name="guideChapter" className="peer appearance-none absolute z-20 size-full inset-0 outline-none lg:hidden" /> {/* 移动端专用触摸媒介 */} <div className="absolute size-full rounded-3xl opacity-80 overflow-hidden hide-scrollbar transition duration-300 before:absolute before:z-10 before:size-24 before:-right-12 before:-top-12 before:rounded-full before:bg-sky-100/50 before:blur-xl after:absolute after:size-30 after:-left-6 after:-bottom-10 after:bg-sky-100/75 after:blur-2xl group-hover:opacity-100 group-has-peer-focus:opacity-100"> {/* 背景容器 */} <span className="absolute block w-[calc(100%-4.25rem)] h-[calc(100%-4.25rem)] rounded-tl-3xl bg-slate-100"></span> {/* 背景:左上角 */} <span className="absolute block w-17 h-[calc(100%-4.25rem)] right-0 rounded-r-3xl bg-slate-100"></span> {/* 背景:右上角 */} <span className="absolute block w-[calc(100%-4.25rem)] h-17 bottom-0 rounded-b-3xl bg-slate-100"></span> {/* 背景:左下角 */} <svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44" className="absolute right-6 bottom-6 fill-slate-100"> <path id="Subtraction_27" data-name="Subtraction 27" d="M0,0 H44 A44,44,0,0,0,0,44 V0 Z"/> {/* 背景:圆角 */} </svg> <span className="absolute block size-14 right-0 bottom-0 p-4.5 rounded-full bg-slate-100"> {/* 背景:右下角圆形 */} <span className="icon icon-form-arrow absolute block opacity-100 transition duration-300 translate-x-0 group-hover:opacity-0 group-hover:translate-x-4 group-has-peer-focus:opacity-0 group-has-peer-focus:translate-x-4"></span> {/* 图标:箭头 */} <span className="icon icon-form-search block opacity-0 transition duration-300 -translate-x-4 group-hover:opacity-100 group-hover:translate-x-0 group-has-peer-focus:opacity-100 group-has-peer-focus:translate-x-0"></span> {/* 图标:阅读 */} </span> </div> <div className="relative flex flex-col justify-center h-full py-7 pl-7 pr-20"> {/* 内容容器 */} <p className="relative flex items-center leading-1 text-sm text-slate-400 before:inline-block before:w-1 before:h-4 before:mr-1 before:rounded-full before:bg-slate-400">Chapter.{index + 1}</p> <h4 className="relative mt-1.5 font-medium text-xl">{item.heading}</h4> <p className="max-h-0 opacity-0 duration-300 group-hover:max-h-20 group-hover:mt-1 group-hover:opacity-100 group-has-peer-focus:max-h-20 group-has-peer-focus:mt-1 group-has-peer-focus:opacity-100">{item.description}</p> {/* 描述文字,默认隐藏,hover 或 focus 时展开 */} </div> <Link href={item.link} {...(item.openInNewTab && { target: '_blank' })} className="absolute block z-30 size-0 right-0 bottom-0 p-4.5 rounded-2xl duration-300 max-lg:peer-focus:size-14 lg:size-full"></Link> {/* 伴随屏幕尺寸改变遮罩状态的 a 标签 */} </div> ))} </div> </div> ) }

代码块暂时不做处理,因为需要额外的高亮插件,我们会放在下一个章节讲解。

解释一下:

  • 外部容器使用 lg:flex 实现桌面端左右布局,移动端则通过 max-lg:space-y-9 保持垂直堆叠;

    后者直接写成 space-y-9 lg:space-y-0 也行,通常只有类名较长且仅在某个范围区间有效才建议用 max-*:**:max-*:* ,视具体情况而定

  • 左侧信息容器在 md:max-lg: 断点(平板尺寸)时使用横向布局,将标题描述与按钮并排显示以优化空间利用;
  • 右侧章节栏使用 grid 布局,通过 md:grid-cols-2 xl:grid-cols-3 实现响应式列数:移动端单列、平板双列、桌面三列;
  • 每个章节卡片使用 group 类配合 peer 机制实现双端交互:
    • 移动端:通过隐藏的 <input type="radio"> 实现点击展开描述文字的效果
    • 桌面端:通过 hover 状态展开描述并切换图标
  • 背景容器采用多层 <span> 拼接实现复杂的圆角形状,右下角使用 SVG 路径绘制特殊圆角,避免使用图片;
  • 使用 max-h-0max-h-20 的过渡配合 opacity 实现描述文字的平滑展开动画;
  • 链接 <Link> 组件通过动态尺寸实现不同交互模式:移动端仅覆盖右下角按钮区域(peer-focus:size-14),桌面端覆盖整个卡片(lg:size-full)。

紧接着是 Playground.js ,我们先来完成布局部分,不过在此之前我们需要在设计稿中查看一下:

Tailwind CSS - 查看设计稿 - Playground - 1 | Rainy Design Studio 雨点设计工作室

按下 ⌘ / CTRL + Y 组合键显示图层,然后直接来到画板 Index - 1080p ,一路双击到内部组件,在左侧图层栏中看到被隐藏的 edge_of_area 图层为止:

Tailwind CSS - 查看设计稿 - Playground - 2 | Rainy Design Studio 雨点设计工作室

使用 ⇧ / SHIFT + 鼠标左键点击多选图层,再按⌘ / CTRL + , 隐藏所有控件,然后将 edge_of_area 图层切换为显示:

Tailwind CSS - 查看设计稿 - Playground - 3 | Rainy Design Studio 雨点设计工作室

我们能看到这是一个宽高为 936×256px ,共计 14 列 4 行,间距为 12px 的网格容器,而且该容器的宽度是写死的。现在我们便可以开始开发了:

@/components/home/Playground.js
// Import Next Components import Link from 'next/link' export default function Playground({ playground, playgroundContents }) { const checkedInput = { 0: "group-has-[ul>li:nth-child(1)>input:checked]:visible", 1: "group-has-[ul>li:nth-child(2)>input:checked]:visible", 2: "group-has-[ul>li:nth-child(3)>input:checked]:visible", 3: "group-has-[ul>li:nth-child(4)>input:checked]:visible" } return ( <div className="customized-container flex flex-col-reverse mt-30 lg:flex-row lg:items-center"> <div className="group flex flex-col p-3 rounded-4xl bg-white space-y-3 overflow-x-hidden hide-scrollbar xl:flex-row xl:flex-wrap xl:max-w-248"> {/* 左侧代码调试场 */} <div className="relative w-full rounded-3xl overflow-x-scroll hide-scrollbar"> {/* 溢出滚动容器,示例渲染结果 */} <div className="w-242 h-72 rounded-3xl bg-slate-200"> {/* 渲染内容容器 */} {playgroundContents && playgroundContents.map((item, index) => ( <div key={index} className={`absolute grid invisible w-234 h-64 grid-rows-4 grid-cols-14 gap-4 inset-4 ${checkedInput[index]}`}>{item.codeblock}</div> ))} </div> </div> <ul className="flex p-3 rounded-3xl bg-slate-100 max-xl:space-x-3 overflow-x-scroll hide-scrollbar xl:flex-col xl:w-60 xl:mb-0 xl:mr-3 xl:space-y-3"> {/* 下方左侧菜单栏 */} {playgroundContents && playgroundContents.map((item, index) => ( <li key={index} className="shrink-0 relative flex items-center w-54 px-5 py-4 rounded-full bg-white transition duration-300 opacity-67 has-peer-checked:opacity-100"> <input type="radio" name="playgroundContents" className="peer appearance-none absolute z-10 size-full inset-0 rounded-full outline-none cursor-pointer" defaultChecked={!index}/> <span className={`icon icon-playground-${item.icon} mr-3`}></span> { item.customizedName ? <p>{item.name}</p> : <span className={`icon text-playground-${item.icon}`}></span> } {/* 如 customizedName 为真则使用文字 */} </li> ))} </ul> <div className="grow max-h-67 rounded-2xl bg-primary"> {/* 下方右侧代码块 */} {playgroundContents && playgroundContents.map((item, index) => ( <pre key={index}> <code>{item.codeblock}</code> {/* 保持代码格式输出 */} </pre> ))} </div> </div> <div className="customized-informations mb-9 lg:max-w-78 lg:mb-0 lg:ml-15"> {/* 右侧信息 */} <h2>{playground.heading}</h2> <p>{playground.description}</p> <Link href={playground.buttonLink} {...(playground.openInNewTab && { target: '_blank' })} className="customized-link-button mt-9"> {playground.buttonText} <span></span> </Link> </div> </div> ) }

解释一下:

  • 代码调试场容器在 xl: 端点下使用 xl:flex-row xl:flex-wrap ,配合子容器的 grow 原子类与宽度限制,能轻松地做到换行撑满的布局效果;
  • 对每个需要的溢出滚动容器设置了 overflow-x-scroll hide-scrollbar ,使其可以单独做横向滚动,不影响其他容器;
  • 将渲染内容容器内外两层的宽高直接写死,以配合父容器(溢出滚动容器)实现滚动效果;
  • 菜单栏通过传入的 customizedName 值判断是否使用自定义文字名称,否则使用图标字体。这样可以避免为每个选项都加载额外的字体资源;
  • 代码块要做代码高亮,所以需要在下个章节安装特定插件,届时再做内部容器的样式调整。

最后是 About.js,这个组件也会在部分页面中被复用,所以存放于 @components/shared 目录下,此外 globals.css 也有所改动:

@/styles/globals.css
... @theme { /* Colors */ --color-primary: var(--color-slate-600); --color-secondary: var(--color-slate-500); /* Size */ --max-width-8xl: 96rem; /* Breakpoints */ --breakpoint-xs: 33rem; /* 新增了一个 xs 断点 */ } ...
@/components/shared/About.js
// Import Next Components import Link from 'next/link' import Image from 'next/image' export default function About({ globalData, data }) { return ( <div className="items-center max-w-8xl mx-auto mt-30 space-y-12 md:space-y-16 lg:flex lg:px-18 lg:space-y-0"> <div className="customized-informations px-6 md:px-9 lg:px-0 lg:w-78"> {/* 左侧信息 */} <h2>{data.heading}</h2> <p>{data.description}</p> <Link href={data.buttonLink} {...(data.openInNewTab && { target: '_blank' })} className="customized-link-button mt-9"> {data.buttonText} <span></span> </Link> </div> <div className="relative pb-4 -mb-4 -ml-6 overflow-hidden md:pb-6 md:-mb-6 lg:grow lg:px-4 lg:ml-8 lg:-mr-4 2xl:px-6 2xl:ml-24 2xl:-mr-6"> {/* 右侧图片 */} <div className="relative max-xs:w-120 max-lg:max-h-96 aspect-video max-xs:ml-[calc(50%-15rem)] xs:mx-auto"> {/* 内部滚动容器 */} { data && data.image.map((item, index) => ( <div key={index} className={`absolute flex justify-center p-1.5 rounded-2xl bg-slate-50 shadow-lg shadow-secondary/16 md:p-2 xl:p-2.5 xl:rounded-3xl 2xl:p-3 2xl:shadow-xl ${!index ? 'aspect-video w-2/3 left-1/6 bottom-16' : index == 1 ? 'aspect-5/4 w-1/3' : index == 2 ? 'aspect-20/11 w-2/5 left-3/10 bottom-4 md:bottom-6' : 'aspect-10/11 w-1/4 right-0 top-1/6'}`}> <Image src={`${process.env.NEXT_PUBLIC_API_URL}${item.url}`} alt={`${item.alternativeText}${globalData.globalAddOn}`} width={item.width} height={item.height} className="absolute size-[calc(100%-4.5rem)] bottom-0 rounded-2xl blur-sm opacity-67 xl:rounded-3xl"/> <Image src={`${process.env.NEXT_PUBLIC_API_URL}${item.url}`} alt={`${item.alternativeText}${globalData.globalAddOn}`} width={item.width} height={item.height} className="relative size-full rounded-2xl xl:rounded-3xl"/> </div> ))} </div> </div> </div> ) }

解释一下:

  • 因为内部容器要做移动端左右两端溢出隐藏,所以不能使用 customized-container 类统一设置 padding-x 值,而是手动设置使用类名,并给信息容器加上 px-* 值;
  • 先前于 Comparison.js 中提到过,溢出隐藏属性会导致阴影不显示,所以我们需要为图片框增加额外的 px-* -mr-* ,并计算减去负值后的 ml-* 值;
  • 内部滚动容器在宽度低于 528px (33rem) 时锁定为 480px (30rem) 并利用 ml-[calc(50%-15rem)] 水平居中。由于这是针对小屏幕的特殊处理,需要在 globals.css 中新增断点变量 --breakpoint-xs: 33rem;
  • 使用 array.map() 循环渲染数组中的四张图片时,由于每张图片只有比例大小及坐标不同,其余布局如阴影、背景、内边距、圆角都是一样的,所以我们可以用 index 做判断分别渲染不同的 utilities:
    • index === 0:16:9 比例,宽度 2/3,作为背景
    • index === 1:5:4 比例,宽度 1/3,位于左上角
    • index === 2:20:11 比例,宽度 2/5,位于中下方
    • index === 3:10:11 比例,宽度 1/4,位于右上角
    • 此外,我们还在图片下方压了一层模糊的图片,以实现一个基于图片的自动阴影。
小提示: !index ? '' : index == 1 ? '' : '' 并非规范格式,正确情况下应使用等值等型判断: index === 0 ? '' : index === 1 ? '' : '' ,不过考虑到此处传入的 index 永远都只能是从 0 到 n 的数字,适当偷懒仅做参考。

这样一来,主页的 Introduction.jsFrameworks.jsComparisons.jsGuideChapters.jsAbout.js 组件就都完成了;不过 Subscription 明显顶到了 About.js 的下边缘,实际上它在每个页面都需要上边距,让我们为其添加一下:

@/components/global/Subscription.js
// Import utilities import { handleFormSubmit } from '@/utils/handleFormSubmit' export default function Subscription({ globalData }) { return ( <div className="py-18 px-6 rounded-3xl mt-30 bg-slate-50 md:px-9 lg:p-18"> {/* 外部容器与背景 */} <div className="justify-between items-center max-w-349 mx-auto lg:flex"> {/* 内部容器,句中定位与布局 */} <div className="mr-2"> {/* 标题与段落 */} <h4 className="text-2xl font-medium">{globalData.subscription.heading}</h4> <p className="mt-4">{globalData.subscription.description}</p> </div> <form onSubmit={(e) => handleFormSubmit(e, '/api/subscriptions')} className="relative inline-block mt-4"> <span className="customized-shadow"></span> {/* 阴影层 */} <span className="absolute w-[calc(100%-.25rem)] h-[calc(100%-.3125rem)] left-0.5 top-0.5 rounded-full bg-linear-to-b from-slate-300 to-slate-100"></span> {/* 背景渐变 */} <input name="subscription" placeholder={globalData.subscription.inputPlaceholder} required className="relative h-13 min-w-72 rounded-full border-white border-2 border-b-3 pl-6 pr-17 focus:outline-slate-400"/> <button type="submit" className="absolute aspect-square h-[calc(100%-.25rem)] top-0.5 right-0.5 p-3.5 rounded-full bg-radial-[at_75%_25%] from-[#bdcde1] to-[#9fb5d6] cursor-pointer"> <span className="icon icon-form-submit block"></span> </button> </form> </div> </div> ) }

Playground.js 组件因涉及代码高亮功能,需要安装额外的语法高亮库(如 Prism.js 或 Highlight.js),我们会于下个章节着重讲解,现在优先完成其他页面的组件样式。

4.4.2 课程页面(Course)

让我们查看一下课程页面的代码:

@/pages/course.js
// Import library import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' import Courses from '@/components/course/Courses' import SpecializedCourses from '@/components/course/SpecializedCourses' import About from '@/components/shared/About' export default function Course({ globalData, data }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo}/> <Information globalData={globalData} data={data.banner}/> <Courses globalData={globalData} data={data.courses}/> <SpecializedCourses globalData={globalData} specializedCourses={data.specializedCourses} specializedCoursesContents={data.specializedCoursesContents}/> <About globalData={globalData} data={globalData.aboutUs}/> </> ) } export async function getStaticProps() { const response = await fetcher(`course?populate[0]=banner&populate[1]=banner.image&populate[2]=courses.image&populate[3]=specializedCourses&populate[4]=specializedCoursesContents.image&populate[5]=seo`) return { props: { data: response.data } } }

课程页面的第一个组件是一个共享组件 Information.js,让我们来编辑一下样式(此处先使用固定背景,稍后会展示如何实现多背景切换):

@/components/shared/Information.js
// Import Next Component import Link from 'next/link' import Image from 'next/image' export default function Information({ globalData, data, position }) { return ( <div className={`relative flex items-center w-full h-screen px-6 pt-6 mask-b-from-67% bg-cover bg-center bg-no-repeat md:px-9 md:pt-9 lg:max-h-180 lg:px-18 lg:pt-18 2xl:px-[calc(50%-43.5rem)] bg-[url('/image/shared/information_course_bg.png')]`}> {/* 使用 Next.JS 图片以获得更好的性能,但会丧失遮罩 */} {/* <Image src={`${process.env.NEXT_PUBLIC_API_URL}${data.image.url}`} alt={`${data.image.alternativeText}${globalData.globalAddOn}`} width={data.image.width} height={data.image.height} className="hidden absolute size-full inset-0"/> */} <div className="relative max-w-120"> {/* 左侧信息 */} <h1 className="text-3xl font-medium uppercase lg:text-5xl">{data.heading}</h1> <p className="mt-6">{data.description}</p> <Link href={data.buttonLink} {...(data.openInNewTab && { target: '_blank' })} className="customized-link-button mt-9"> {data.buttonText} <span></span> </Link> {/* 条件判断语句,如果 data.openInNewTab 为真才显示 target 参数与对应属性 */} </div> </div> ) }

解释一下:

  • 通过设定 lg:max-h-180 来让该组件在桌面端高度不至于撑得过高;
  • 使用 mask-b-from-67% bg-* 以应用 CSS mask 属性,实现自下而上的背景遮罩效果。
    • 当然我们可以把这些原子类名全部删掉,再取消图片的注释,以应用 Next 图片获得更好的性能,这样也可以将静态资源统一交给 Strapi 管理;代价则是丧失遮罩效果,需要手动设计边缘半透明的图片。

如果要根据页面设置多个图片背景的话,请参考下列代码:

@/pages/course.js
... export default function Course({ globalData, data }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo}/> <Information globalData={globalData} data={data.banner} position="course"/> {/* 传入 position */} <Courses globalData={globalData} data={data.courses}/> <SpecializedCourses globalData={globalData} specializedCourses={data.specializedCourses} specializedCoursesContents={data.specializedCoursesContents}/> <About globalData={globalData} data={globalData.aboutUs}/> </> ) } ...
@/components/shared/Information.js
// Import Next Component ... export default function Information({ globalData, data, position }) { const backgroundClasses = { "course": "bg-[url('/image/shared/information_course_bg.png')]", "blog": "bg-[url('/image/shared/information_blog_bg.png')]", "about": "bg-[url('/image/shared/information_about_bg.png')]" } return ( <div className={`relative flex items-center w-full h-screen px-6 pt-6 mask-b-from-67% bg-cover bg-center bg-no-repeat md:px-9 md:pt-9 lg:px-18 lg:pt-18 2xl:px-[calc(50%-43.5rem)] ${backgroundClasses[position]}`}> ...

这样我们只需要替换相应的图片,并在后续由 About 页面引入 Informations.js 时再次手动添加 position 即可。下一个是 Courses.js ,考验我们对背景图片的对齐与 grid 网格的运用熟练度:

@/components/course/Courses.js
// Import Next components import Image from 'next/image' import Link from 'next/link' export default function Courses({ globalData, data }) { return ( <div className="customized-container pb-12 mx-auto -mt-15 -mb-12 overflow-x-scroll hide-scrollbar"> {/* 外部容器 */} <div className="flex grid-cols-3 grid-rows-2 w-303 p-3 rounded-3xl bg-slate-50 gap-3 shadow-xl shadow-primary/12 lg:grid lg:w-auto lg:h-160"> {/* 溢出滚动容器 */} {data && data.map((item, index) => ( <div key={index} className={`relative w-72 h-90 p-6 rounded-3xl overflow-hidden lg:size-auto ${!index ? 'col-span-2' : index == 1 ? 'row-span-2' : ''}`}> {/* 课程卡片 */} <Image src={`${process.env.NEXT_PUBLIC_API_URL}${item.image.url}`} alt={`${item.image.alternativeText}${globalData.globalAddOn}`} width={item.image.width} height={item.image.height} className={`absolute min-w-312 h-auto inset-0 object-cover -top-54 lg:min-w-360 ${!index ? 'left-0 lg:left-[-3%] lg:-top-20' : index == 1 ? '-left-82 lg:left-[-216%] lg:-top-20' : index == 2 ? '-left-158 lg:-top-92 lg:left-[-3%]' : index == 3 ? '-left-234 lg:-top-98 lg:left-[-108%]' : ''}`}/> <Link href={item.link} {...(item.openInNewTab && { target: '_blank' })} className="group relative"> <div className="absolute flex justify-center items-center size-9 rounded-full bg-slate-50 lg:size-13"> <span className={`icon icon-course-${item.icon} scale-75 lg:scale-100`}></span> </div> <h3 className="mt-2 ml-12 font-medium group-hover:underline lg:mt-3 lg:ml-16 lg:text-xl">{item.heading}</h3> </Link> </div> ))} </div> </div> ) }

解释一下:

  • 使用 -mt-15 将外容器向上覆盖 3.75rem (60px) 以还原设计稿效果;
  • 溢出滚动容器中的 grid-cols-3 grid-rows-2 会在 lg: 断点下配合 grid 切换为三列二行的网格布局;
  • 当移动端时,卡片固定为 w-72 h-90,通过设定图片尺寸大于所有卡片宽度之和,配合 -left-* 偏移值,可以让每张卡片显示同一张大图的不同区域,实现拼图效果;
    • 此处要求图片的比例为 16:9,且宽度至少为 1600px(如 1600×900),以确保在移动端横向排列时能完整覆盖所有卡片。
  • 当卡片的尺寸在 lg: 断点下跟随父级容器(父元素的原子类名为 lg:grid lg:w-auto lg:h-160),变为响应式宽度(lg:size-auto ${!index ? 'col-span-2' : index == 1 ? 'row-span-2' : ''})时,通过直接偏移相对父元素宽度的百分比(lg:left-[-*%])就可以让图片牢牢固定在对应的坐标上;
    • index === 0:第一张卡片,占据 2 列(col-span-2
    • index === 1:第二张卡片,占据 2 行(row-span-2
    • 其余卡片:默认占据 1 列 1 行

    不理解具体影响的,可以在浏览器内打开开发者工具并启用设备工具栏(⌘ / CTRL + ⇧ / SHIFT + M),随意拖动窗口宽度以查看图片的响应式变化。

最后是 SpecializedCourses.js

@/components/course/SpecializedCourses.js
// Import Next components import Image from 'next/image' export default function SpecializedCourses({ globalData, specializedCourses, specializedCoursesContents }) { const checkedInputInList = { 0: "group-has-[ul>li:nth-child(1)>input:checked]:opacity-100", 1: "group-has-[ul>li:nth-child(2)>input:checked]:opacity-100", 2: "group-has-[ul>li:nth-child(3)>input:checked]:opacity-100", 3: "group-has-[ul>li:nth-child(4)>input:checked]:opacity-100", 4: "group-has-[ul>li:nth-child(5)>input:checked]:opacity-100", } const availableInputInContainer = { 0: "group-has-[ul>li:nth-child(1)>input:checked]:z-10", 1: "group-has-[ul>li:nth-child(2)>input:checked]:z-10", 2: "group-has-[ul>li:nth-child(3)>input:checked]:z-10", 3: "group-has-[ul>li:nth-child(4)>input:checked]:z-10", 4: "group-has-[ul>li:nth-child(5)>input:checked]:z-10", } return ( <div className="customized-container group mt-30 max-lg:space-y-9 lg:flex"> <div className="space-y-9 lg:w-1/2 lg:max-w-120 lg:py-16"> {/* 左侧容器 */} <div className="customized-informations lg:max-w-78"> {/* 信息容器 */} <h2>{specializedCourses.heading}</h2> <p>{specializedCourses.description}</p> </div> <div className="relative w-screen -ml-6 overflow-x-scroll overflow-y-hidden hide-scrollbar lg:w-auto lg:ml-0 lg:overflow-visible"> {/* 溢出滚动容器 */} <ul className="flex w-5xl px-6 max-lg:space-x-4 md:px-9 lg:flex-col lg:justify-stretch lg:w-auto lg:px-0"> {/* 课程列表 */} {specializedCoursesContents && specializedCoursesContents.map((listItem, listIndex) => ( <li key={listIndex} className="group/li grow shrink-0 relative flex items-center w-44 space-x-3 max-lg:opacity-67 transition duration-300 max-lg:has-peer-checked:opacity-100 lg:w-full lg:px-4 lg:py-3 lg:rounded-l-3xl lg:bg-white/0 lg:hover:bg-white/67 lg:has-peer-checked:bg-white"> <input type="radio" name="specializedCourses" className="peer appearance-none absolute z-10 size-full inset-0 rounded-full outline-none cursor-pointer" defaultChecked={!listIndex}/> <span className="shrink-0 relative flex justify-center items-center size-12 bg-white rounded-full"> <span className={`icon icon-specialized-course-${listItem.icon}`}></span> </span> <h3>{listItem.heading}</h3> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="96" viewBox="0 0 12 96" className="absolute -top-3 right-0 fill-white/0 transition duration-300 lg:peer-checked:fill-white lg:group-hover/li:fill-white/67"> <path d="M12,0 A12,12,0,0,1,0,12 H12 V0 Z"/> <path d="M0,84 A12,12,0,0,1,12,96 V84 H0 Z"/> </svg> </li> ))} </ul> </div> </div> <div className="relative h-60 p-3 lg:w-[calc(100%-30rem)] lg:h-auto"> {/* 右侧图片容器 */} <span className="customized-shadow"></span> {/* 阴影层 */} <span className="absolute block size-full inset-0 rounded-2xl bg-white lg:rounded-3xl"></span> {/* 装饰背景 */} {specializedCoursesContents && specializedCoursesContents.map((contentItem, contentIndex) => ( <div key={contentIndex} className={`absolute size-full inset-3 ${availableInputInContainer[contentIndex]}`}> <div className={`absolute size-[calc(100%-1.5rem)] opacity-0 transition duration-300 ${checkedInputInList[contentIndex]}`}> {/* 图片定位容器 */} <input type="button" name="specializedCoursesImage" className="peer appearance-none absolute z-10 size-full inset-0 rounded-full outline-none transition duration-100 focus:size-0 xl:hidden"/> <Image src={`${process.env.NEXT_PUBLIC_API_URL}${contentItem.image.url}`} alt={`${contentItem.image.alternativeText}${globalData.globalAddOn}`} width={contentItem.image.width} height={contentItem.image.height} loading="lazy" className="size-full object-cover rounded-2xl lg:rounded-3xl"/> <p className="absolute flex inset-4 px-8 text-primary/0 select-none transition duration-300 peer-focus:text-primary hover:text-primary before:absolute before:size-6 before:left-0 before:bg-white/75 before:rounded-full before:animate-[ping_2s_ease-out_infinite] after:absolute after:size-4 after:inset-1 after:bg-white after:rounded-full">{contentItem.description}</p> </div> </div> ))} </div> </div> ) }

解释一下:

  • 通过对父级容器添加 lg:flex 使其在 lg: 断点下切换成横向排布,然后左侧容器配合添加 py-* 与宽度限制,来实现竖向菜单下方平滑过渡圆角,不至于顶到边缘而太突兀;
  • 在每个 <li> 中插入一个特定 SVG,用于在 lg: 断点下实现右侧的两个圆角切口效果(上下各一个 12px 的圆角)。SVG 通过 peer-checked:fill-white 实现选中状态的填充色变化;
  • 图片交互实现了双端适配:
    • 移动端/平板(< xl):点击图片触发隐藏的 <input> 获得焦点,通过 peer-focus: 显示描述文字;
    • 桌面端(≥ xl)<input> 尺寸缩小为 0,<p> 覆盖整个图片区域,通过 hover: 直接显示描述文字;
    • 使用 before:after: 伪元素创建脉动动画效果,提示用户可以交互。

其余都是些先前实现过的技巧,此处就不一一赘述了。

至此,course 页面的开发也告一段落了,接下来是 blog 页面。

4.4.3 博客页面(Blog)

首先打开博客页面,为 Information 组件传入 position :

@/pages/blogs.js
// Import library import { fetcher } from '@/lib/api' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' import FeaturedArticles from '@/components/blog/FeaturedArticles' import ArticleCardContainer from '@/components/shared/ArticleCardContainer' export default function Blog ({ globalData, data, latestArticles }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo}/> <Information globalData={globalData} data={data.banner} position="blog"/> <FeaturedArticles globalData={globalData} articlesOfTheDay={data.articlesOfTheDay} articlesOfTheDay2={data.articlesOfTheDay2} latestArticles={latestArticles} /> <ArticleCardContainer globalData={globalData} data={data.featuredCategories}/> </> ) } export async function getStaticProps() { const response = await fetcher(`blog?populate[0]=articlesOfTheDay.author&populate[1]=articlesOfTheDay.category&populate[2]=articlesOfTheDay.cover&populate[3]=articlesOfTheDay2.author&populate[4]=articlesOfTheDay2.category&populate[5]=articlesOfTheDay2.cover&populate[6]=banner.image&populate[7]=featuredCategories.articles.cover&populate[8]=featuredCategories.articles.author&populate[9]=seo`) const latestArticles = await fetcher(`articles?sort=publishedAt:desc&populate=*&pagination[limit]=3`) return { props: { data: response.data, latestArticles: latestArticles.data } } }

根据先前设定好的参数,Information.js 会自动读取 Next.JS 项目下的 /image/shared/information_blog_bg.png 作为背景,数据都从 Strapi 获取,而样式是复用的,就不用做修改了,我们只需完成 FeaturedArticles.jsArticleCardContainer.js 这两个部分,而查看两者的代码我们会发现内部共用一个子组件 ArticleCard.js ,所以我们要优先处理它。不过按照惯例,我们先来创建一些复用的组件类名:

@/styles/globals.css
... @layer components { ... .customized-icon-con { ... } .customized-article-tag-container { @apply flex flex-wrap gap-4; } .customized-article-tag { @apply px-4 py-1.5 rounded-full border border-primary; } }

ok,现在开始编辑 ArticleCard.js

@/components/shared/ArticleCard.js
// Import libraries import formatDate from '@/lib/formatDate' // Import Next components import Image from 'next/image' import Link from 'next/link' export default function ArticleCard({ globalData, data, tag, position }) { const formatedDate = formatDate(data.publishedAt) // 格式化日期 const articleCardStatus = { // 映射 position 为显式原子类名 "articles-of-the-day": "", "latest": "flex-1" } return ( <div className={`relative space-y-4 ${articleCardStatus[position]}`}> <Link href={`/blog/${data.slug}`} className="block"> <Image src={`${process.env.NEXT_PUBLIC_API_URL}${data.cover.url}`} alt={`${data.cover.alternativeText}${globalData.globalAddOn}`} width={data.cover.width} height={data.cover.height} loading="lazy" className="block max-h-72 rounded-3xl 2xl:max-h-120"/> {/* 从 data.cover 对象获取所有数据,包括图片宽高 */} </Link> <div className="flex flex-col px-6"> {/* 下部容器 */} <Link href={`/blog/${data.slug}`}> <h4 className="text-xl font-medium">{data.title}</h4> </Link> <div className="flex items-center leading-tight mt-2"> {/* 作者与时间 */} <p>{data.author.name}</p> {formatedDate && ( <> <span className="mr-2">,</span> <p className="text-slate-400">{formatedDate}</p> </> )} </div> <div className="customized-article-tag-container mt-4"> {/* 文章 Tag 栏 */} <Link href={`/category/${tag.slug}`} className="customized-article-tag">{tag.name}</Link> {/* 传入并渲染 Tag 数据为 HTML */} </div> </div> </div> ) }

解释一下:

  • 添加了一个 position 接口,将传入的数据映射到对应的类名组方便调用;
  • max-h-* 限定图片高度,使其在规定断点下不至于过高;
  • 因为作者与时间不需要换行,所以我们可以给该容器添加 leading-tight 使子元素缩小字体自带的上下间距,然后将逗号单独拆出来,如果 formatedDate 值不存在或未定义,那么逗号和日期都将不显示;
  • .customized-article-tag-container 应用到 tag 容器,然后将 customized-article-tag 应用到 tag ,如此一来就可以完成对文章卡片的配置了。

接下来看看 FeaturedArticles.js

@/components/blog/FeaturedArticles.js
// Import components import ArticleCard from '@/components/shared/ArticleCard' export default function FeaturedArticles({ globalData, articlesOfTheDay, articlesOfTheDay2, latestArticles }) { return ( <div className="relative max-w-348 py-9 -mt-15 rounded-3xl bg-slate-50 max-2xl:space-y-9 shadow-lg shadow-secondary/16 max-lg:overflow-x-scroll hide-scrollbar lg:mx-18 2xl:flex 2xl:justify-stretch 2xl:mx-auto"> <div className="flex flex-col px-6 space-y-6 md:px-9"> {/* 左侧每日推荐 */} <p className="mx-6 text-2xl font-medium">Articles of the day</p> <div className="space-y-6 items-stretch gap-6 md:max-2xl:flex"> <ArticleCard globalData={globalData} data={articlesOfTheDay} tag={{ name: articlesOfTheDay.category.name, slug: articlesOfTheDay.category.slug }} position="articles-of-the-day"/> {/* 直接为 tag 传入 category 中的数据,下同 */} <ArticleCard globalData={globalData} data={articlesOfTheDay2} tag={{ name: articlesOfTheDay2.category.name, slug: articlesOfTheDay2.category.slug }} position="articles-of-the-day"/> </div> </div> <div className="flex flex-col space-y-6 2xl:max-w-110"> {/* 右侧最新发布 */} <p className="px-6 mx-6 text-2xl font-medium md:px-9 2xl:pl-0">Latest</p> <div className="overflow-x-scroll hide-scrollbar"> <div className="flex items-stretch gap-6 max-lg:min-w-234 px-6 md:px-9 2xl:flex-col 2xl:pl-0"> {/* 溢出滚动容器 */} {latestArticles && latestArticles.map((article, index) => { const tag = { name: article.category.name, slug: article.category.slug } return ( <ArticleCard key={index} globalData={globalData} data={article} tag={tag} position="latest"/> ) })} </div> </div> </div> </div> ) }

解释一下:

  • 默认移动端时容器为全宽,直至 lg: 断点给两边加上外边距以还原设计稿;
  • 为标题添加 mx-6 来左对齐每个文章卡片的文字内容;
  • 左侧容器仅在 md:xl: 端点间应用左右排布,所以用 md:max-2xl:flex 规定弹性布局应用断点范围;
  • 为两个位置的子组件传入不同的 position 以控制样式变化。

最后我们来看 ArticleCardContainer.js

@/components/shared/ArticleCardContainer.js
// Import Next components import Link from 'next/link' // Import components import ArticleCard from '@/components/shared/ArticleCard' // 引入博客卡片 export default function ArticleCardContainer ({ globalData, data }){ return ( <div className="mt-30 space-y-30 lg:max-w-8xl lg:px-18 lg:mx-auto"> {data && data.map((category, categoryIndex) => { // 数组循环 category const tag = { name: category.name, slug: category.slug } // 定义需要传入的 prop 与数据 return category.articles.length ? ( // 判断 category.article 是否选中了文章,如果一篇都没有,此处数组为空,则不做渲染 <div key={categoryIndex} className="space-y-6"> {/* 渲染每个 category 盒模型 */} <div className="px-6 mx-6 max-lg:space-y-3 md:px-9 lg:flex lg:justify-between lg:items-center lg:px-0"> {/* 上半部分名称与链接 */} <h3 className="text-2xl capitalize">{category.name}</h3> <Link href={`/category/${category.slug}`}>View all &gt;&gt;</Link> </div> <div className="max-lg:overflow-x-scroll hide-scrollbar"> {/* 下半部分卡片容器 */} <div className={`gap-6 px-6 md:grid md:grid-cols-2 md:px-9 lg:px-0 xl:grid-cols-3 ${position === 'latest' && 'max-lg:w-240'}`}> {/* 数组循环 category.articles */} {category.articles && category.articles.map(( articleCard, articleCardIndex) => ( <ArticleCard key={articleCardIndex} globalData={globalData} data={articleCard} tag={tag}/> ))} </div> </div> </div> ) : null })} </div> ) }

这块结构非常简单,需要讲解的只有一个拉丁文语系环境下需注意的要点(纯汉语环境(<html lang="zh-cn">)不做考虑) ,那就是 category.name 是以标准的 lowercase 写法存储的字符串数据,但当我们要将其作为一个标题显示时,每行首个单词的首字母必须为大写,所以这里我们为目录名称添加一个 capitalize 原子类以应用首字母大写。

此外,目前 Blog 页面并未实现搜索功能,而是仍在使用公用的 Introduction.js 模块。我们将于下一个章节仔细讲解该内容。

以上内容确认完毕后,我们就可以来处理导航条中的最后一个单页 —— 关于页面了。

4.4.4 关于页面(About)

接下来来到关于页面,我们可以看到这个页面的内容像是一篇博客文章的正文部分,实际上也确实如此,所以我们需要借助先前封装好的 Markdown 插件实现正文内容的渲染,此外还需要一点点 DOM 的改动,不然渲染的结果就是所有内容都平铺在 <main> 的子集层级内,与 Information.js 作为同级元素一并显示了。以下是改动好的代码:

@/pages/about.js
// Import Libraries import Markdown from '@/lib/markdown' // Import components import CustomizedHead from '@/components/global/CustomizedHead' import Information from '@/components/shared/Information' export default function About({ globalData, data }) { return ( <> <CustomizedHead globalData={globalData} data={data.seo} /> <Information globalData={globalData} data={data.banner} position="about"/> <div className="customized-container"> {/* 内容容器 */} <section> {/* 使用 section 标签承载正文以符合 w3c 标准 */} <Markdown data={data.body} /> </section> </div> </> ) } export async function getStaticProps() { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/about?populate[0]=banner.image&populate[1]=seo`) const response = await res.json() return { props: { data: response.data } } }

接着我们需要在 globals.css 中为 <section> 内渲染的内容逐一添加样式。这些样式需要加入到 @layer base 之中,先让我们如下代码块所示添加好 base 层:

@/styles/globals.css
@import "tailwindcss"; @source "../pages/**.*"; @source inline("underline font-semibold text-blue-500"); @theme { ... } @layer base { ... } @layer components { ... } @layer utilities { ... }
小提示:Tailwind CSS v4 的默认 import 顺序是 @layer theme, base, components, utilities; ,虽然在该文件中改变顺序并不影响层级优先级,不过为了匹配官方约定,我们建议将 @layer base 放在 @layer theme@layer components 层之间。

接着,为 @layer base 添加如下内容:

@/styles/globals.css
... @layer base { /* Markdown in section */ /* Markdown: preflights */ section { @apply relative text-primary; } section * { @apply relative; } section > * + *, section > div > div + *, section > div > div > * + *:not(img){ @apply mt-4; } /* Markdown: elements */ /* Markdown: headings */ section > h1, section > h2, section > h3, section > h4, section > h5, section > h6 { @apply pt-12 font-medium scroll-mt-9 md:scroll-mt-12 lg:scroll-mt-16 xl:scroll-mt-20; } section > h1{ @apply text-4xl; } section > h2{ @apply text-3xl; } section > h3{ @apply text-2xl; } section > h4{ @apply text-xl; } section > h5{ @apply text-lg; } section > h6{ @apply text-base; } /* Markdown: a */ section a{ @apply inline text-sky-600 underline underline-offset-1 decoration-sky-600 transition duration-300; @variant hover { @apply text-sky-500 decoration-sky-500; } } /* Markdown: img */ section > img { @apply max-w-full h-auto mx-auto object-cover object-center; } section div:has(> img:nth-child(1), > img:nth-child(2)) { @apply flex space-x-4 overflow-x-scroll; scrollbar-width: none; -ms-overflow-style: none; &::-webkit-scrollbar { visibility: hidden; } } section div > img { @apply flex-1 xs:min-w-78 md:min-w-85 2xl:min-w-0; } /* Markdown: lists */ section > ul, section > ol { @apply pl-9; } section > ul > li, section > ol > li { @apply mt-2; @variant before { @apply absolute size-1.5 -left-6 top-2.5 rounded-full bg-primary; } } section > ol { counter-reset: list-number; } section > ol > li { @variant before { @apply w-9 left-9 text-center; content: counter(list-number) '.'; counter-increment: list-number; } } section > ul > li > *, section > ol > li > *{ @apply first:mt-1.5 nth-[1n+2]:my-1.5 last:mt-1.5 last:mb-2; } /* Markdown: table */ section > table { @apply block overflow-x-scroll overflow-y-hidden lg:w-full lg:table; scrollbar-width: none; -ms-overflow-style: none; &::-webkit-scrollbar { visibility: hidden; } } section > table > td { @apply p-3; } section > table > tr > th, section > table > tr > td { @apply py-3 px-4; } section > table > thead, section > table > tbody { @apply w-full; } /* Markdown: form */ /* Markdown: br, hr, b, strong, em, i, del, u, ins and nested strong */ section hr { @apply border-slate-600; } section strong, section b { @apply font-medium; } section del { @apply decoration-primary; } section ins { @apply font-light not-italic; } section em > strong { @apply font-semibold not-italic; } section strong > strong, section b > strong { @apply font-bold; } /* Markdown: pre, code, kbd, swap, blockquote */ section code { @apply border border-primary rounded-xl; } section > code { @apply block my-3; } section pre code { @apply block; } section *:not(pre) code { @apply py-0.5 px-1 mx-1.5 text-sm break-all; } section kbd { @apply inline-block -top-px px-1.5 -mt-0.5 mx-1.5 border-[.0625rem_.0625rem_.25rem_.0625rem] border-primary rounded text-sm bg-secondary/33; } section br + kbd { @apply mt-2 mx-1.5; } section samp { @apply p-1 mx-1.5 border border-primary rounded text-sky-600; } section blockquote { @apply py-3 pl-9 pr-2 bg-secondary/10; @variant before { @apply absolute w-2 h-full inset-0 rounded-l bg-secondary/33; } } /* Markdown: others */ section p.caption{ @apply block mt-2 text-secondary text-center italic; } section div:has(div > img:nth-child(2), div > p.caption) { & > div + p.caption { @apply hidden; } & > div:has(img:nth-child(2)) + p.caption { @apply max-md:block; } & > div:has(img:nth-child(3)) + p.caption { @apply max-xl:block; } & > div:has(img:nth-child(4)) + p.caption { @apply max-2xl:block; } } } ...

再次回到 about 页面我们便可以看到样式渲染结果了。

4.5 Tailwind CSS:章节知识点汇总

现在我们已经掌握了如何使用 Tailwind CSS 为网站应用精美的样式,并添加了一些动画、实现了响应式布局,让我们回顾一下这个大型章节都包含了哪些知识点:

  • Tailwind CSS 简介

    • 概念:原子类优先的 CSS 框架,以原子类在标记中直接构建样式;v4 以 CSS 指令与主题变量为核心,弱化 JS 配置
    • 适用场景:设计系统落地、营销站快速搭建、组件库样式约束,作为布局框架与各类现代化前端框架集成
    • 运行环境:任意前端构建链路(Vite/Next/Webpack 等),也提供 standalone 版本
  • Tailwind CSS 特性

    • 全部特性:原子类集合、主题变量体系、响应式与状态变体、指令与函数、插件系统、按需构建与体积优化
    • 原子类集合:排版/布局/色彩/间距/动效等原子类可组合
    • 主题变量体系:用 @theme 定义令牌,自动映射 CSS 变量并驱动类名
    • 响应式与状态变体:sm:/md:/hover:/focus:/aria-
    • 指令与函数:@source@theme@layer
    • 体积优化:按需生成、构建期裁剪
  • Tailwind CSS 核心概念

    • Utility-first:以原子类组合页面,减少自定义选择器
    • Theme Variables:集中声明色彩/间距/字体/半径等设计令牌
    • Layers:@layer base/components/utilities 分层与优先级管理
    • Functions & Directives:theme() 读取令牌,@plugin 扩展能力
    • Variants:断点与交互前缀化生成条件类
    • @source: 声明输入源,包括文件来源与内联原子类名
  • Tailwind CSS 最佳实践

    • globals.css
      • 输入源管理:集中引入 Tailwind 指令与基线样式
      • 内联类名源:使用 @source inline 添加内联类名源
      • 主题管理:用 @theme 定义 color/spacing/font/radius 等并支持切换
      • 层管理:base 放重置与排版,components 放复用块,utilities 补原子
    • 基于 Layer 的现代化优先级管理
      • 分层职责清晰:base 承担重置与排版基线,components 表达语义化片段,utilities 负责原子级覆盖
      • 顺序即优先级:保证引入顺序为 base → components → utilities,必要时自定义层并严格控制插入位置
      • 面向覆盖设计:优先通过更靠后的层与更具体的选择器(如变体前缀)实现覆盖,避免 !important
    • 响应式设计
      • 移动端优先:默认样式无前缀,逐步在 sm:/md:/lg:/xl: 上增强,减少回退与覆盖
      • 断点令牌化:在 @theme 中集中定义断点,保证全站一致的命名与数值来源
      • 状态与断点组合:除 hoversm:/md: 等可叠加,还可用 md:max-lg 组合选择范围,兼顾交互与布局适配
      • 条件加载与性能:仅在需要的断点添加类名,避免为所有尺寸重复声明,控制最终 CSS 体积
    • 原子类名优化
      • 优化原子类次序:手动排布最佳,或使用代码格式化插件如 Prettier 进行一键排序
      • 限定范围、提取重复组合:用 components 层抽象语义化类,并使用 @variant 注册状态变化与伪类、伪元素
      • 向下控制优先:优先规定父元素的布局与子元素分割模式,如 flex/grid/gap/space-*/divide-*
      • 变形代替位移:优先 transform,配合过渡与 will-change
      • 限定动画类型:使用 transitiontransition-* 控制动画类型



稍作休息,然后一起来为项目做最后的精细化配置与状态处理吧。

下一节课程:React.JS (JSX) :使用 ReactMarkdown 插件美化正文

订阅

解锁深度且前沿的前端技术、设计理念与用户交互探寻之道。