前置知识: HTML5、CSS

交互控制与滚动工具类

12 min入门

Tailwind CSS 交互族全集:cursor/user-select/pointer-events/touch-action、scroll-snap 零 JS 轮播与 scroll-margin 锚点让位、accent-color 原生控件重绘、sr-only 与 forced-colors 可达性,附轮播、长按防误触、表单重绘三例

知识点地图

  • 知识类别:Tailwind 官方分类中的 Interactivity(交互控制)一族——指针行为(cursor/pointer-events)、选择行为(user-select)、触摸行为(touch-action)、滚动行为(scroll-behavior/snap/margin/padding)、原生控件重绘(accent-color/appearance/resize)、性能声明(will-change)与可达性工具(sr-only/forced-colors-adjust)。
  • 解决什么问题:布局与动效之外的”交互手感”——能不能点、能不能选中、长按会不会误触、滚动停在哪个位置、原生控件颜色跟不跟主题。
  • 什么时候用到:做轮播与横向滑动列表、防文案误选、自定义表单控件颜色、做锚点导航、给屏幕阅读器补语义文本。
  • 相邻分工:状态变体(hover:/focus:)见变体引擎与交互状态,过渡与动画见过渡与动画——本篇管”行为开关”,它们管”状态样式”。

前置知识

学习目标

  • 能用 pointer-events-none 与 disabled: 各解决对的问题,说清两者的语义区别。
  • 能用 scroll-snap-* 三件套写出零 JS 的图片轮播与横向卡片流。
  • 能用 scroll-mt-* 修好”锚点跳转被吸顶导航遮住”的经典 bug。
  • 能用 accent-color 让原生 checkbox/radio/range 跟上品牌色,知道它与 appearance-none 全自绘的分工。
  • 会用 sr-only 给图标按钮补语义,用 forced-colors-adjust 处理高对比度模式。

0. 交互是”行为开关”

布局决定”在哪”,视觉决定”长什么样”,还有一族属性决定”行为”:鼠标悬上去是什么光标、文字能不能被选中、触摸滑动交给谁处理、滚动停止时对齐到哪里。这一族没有布局类那么显眼,却直接决定页面的”手感”——移动端长按文字弹出错误的全选菜单、锚点跳转后标题被吸顶栏遮住一半、原生勾选框的绿色与品牌蓝打架,全是这一族的活没干。

心智模型:把交互族记成四张开关面板——指针面板(cursor、pointer-events)、选择与触摸面板(user-select、touch-action)、滚动面板(scroll-behavior、scroll-snap、scroll-m/p)、控件与可达性面板(accent-color、appearance、resize、sr-only、forced-colors-adjust)。用到哪张开哪张。

1. 指针面板:cursor 与 pointer-events

1.1 cursor:光标说什么话

类名效果典型场景
cursor-pointer手型可点击的卡片、自定义按钮
cursor-not-allowed禁止符号禁用操作(配合禁用态)
cursor-wait / cursor-progress等待请求进行中
cursor-grab / cursor-grabbing抓取可拖拽列表
cursor-text文本 I 型可选中文字区
cursor-default默认箭头取消误设的手型
html
<!-- 整卡可点:光标手型是"可点"的视觉承诺 -->
<article class="cursor-pointer rounded-xl border p-4 hover:shadow-md">点我查看详情</article>

<!-- 提交中:按钮禁用 + 等待光标,双重反馈 -->
<button disabled class="cursor-wait rounded-md bg-blue-600 px-4 py-2 text-white opacity-60">
  提交中...
</button>

讲解:cursor-pointer 是”承诺”,不是功能——它只换图标,不产生点击。原生 <button> 浏览器默认箭头光标,很多设计系统统一给按钮补 cursor-pointer;反过来,装饰性 span 上误设手型是最常见的”假按钮”问题源:光标说能点、点了没反应,比不能点更伤体验。

1.2 pointer-events:让元素”对指针隐身”

pointer-events-none 让元素完全不理会鼠标/触摸事件——点击”穿透”到它下面的元素:

html
<!-- 场景一:装饰层不拦点击 -->
<div class="relative">
  <input class="w-full rounded-lg border px-10 py-2" placeholder="搜索课程" />
  <!-- 放大镜图标盖在输入框上,但不能挡住点击聚焦 -->
  <svg class="pointer-events-none absolute top-2.5 left-3 size-5 text-gray-400" ... />
</div>

<!-- 场景二:轮播的非当前页禁止交互 -->
<div class="pointer-events-none opacity-0 data-[active]:pointer-events-auto data-[active]:opacity-100 ...">
  非激活的幻灯片点不到内部链接
</div>

讲解:图标叠在输入框上是 pointer-events-none 的教科书场景——不加它,用户点”放大镜”位置时焦点落不到输入框。注意 055 篇幻灯片骨架里 data-[active]:pointer-events-auto 的用法:先全局关、激活时开,是”隐藏层不误触”的标配组合。

易错点:pointer-events-none 与 disabled 的语义区别。 两者都会让点击无效,但语义完全不同:disabled: 变体加在真实禁用的表单控件上——控件进入禁用状态,表单不提交、屏幕阅读器宣读”不可用”、可以配 disabled:opacity-50 出视觉;pointer-events-none 只是”指针不理它”——元素仍是启用状态、仍可被键盘 Tab 聚焦、仍会触发 hover 之外的逻辑。该禁用表单控件时用 disabled 属性 + disabled: 变体;只是想让装饰层让路才用 pointer-events-none。拿后者冒充禁用,键盘用户与读屏用户会被骗。

2. 选择与触摸面板:user-select 与 touch-action

2.1 user-select:文字能不能被选中

类名效果场景
select-none禁止选中按钮、标签、拖拽把手
select-text允许选中(默认)正文
select-all点击全选验证码、短链接、序列号
html
<!-- 课程卡片:标题与按钮禁选,长按不会弹出全选菜单 -->
<article class="select-none rounded-xl border p-4">
  <h3 class="font-semibold">继续播放:第 4 课</h3>
  <button class="mt-2 rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white">继续学习</button>
</article>

<!-- 序列号一键全选:点击即选中整个字符串,复制零误差 -->
<code class="select-all rounded bg-gray-100 px-2 py-1 text-sm">FDEX-2026-8839-XQ</code>

讲解:移动端”长按按钮文字弹出系统选择菜单”是高频翻车点,select-none 一行治好。正文永远不要禁选——用户选不了文章里的句子去搜索,是可访问性倒退。select-all 的典型用户是序列号与激活码:手抖只选到一半是复制错误的主要来源。

2.2 touch-action:滑动交给谁

touch-action 决定触摸滑动由浏览器默认行为(滚动/缩放)还是留给自定义手势:

类名效果场景
touch-none完全交给页面代码画板、自定义拖拽
touch-pan-y只允许垂直滚动横向轮播(横滑自己处理,纵滑还归页面)
touch-pan-x只允许水平滚动纵向拖拽组件
touch-manipulation允许滚动缩放、禁双击缩放延迟普通可滚动区域的性能微调
html
<!-- 手写签名板:touch-none 阻止手指滑动时页面跟着滚 -->
<canvas class="h-48 w-full touch-none rounded-lg border-2 border-dashed"></canvas>

<!-- 横向卡片流:横向由 snap 管,纵向滚动仍顺畅透传给页面 -->
<div class="flex touch-pan-y gap-4 overflow-x-auto snap-x">...</div>

讲解:不写 touch-action 时,用户在轮播上想”竖着滚页面”,手指斜一点就触发了横向 snap 拖拽,页面”卡住”——touch-pan-y 明确”横向我管、纵向让路”,是横向滑动组件的必写项。

3. 滚动面板:behavior、snap 与 margin/padding

3.1 scroll-behavior:平滑滚动

html
<!-- html 根元素开启平滑滚动,锚点跳转自带过渡 -->
<html class="scroll-smooth">
  ...
</html>

scroll-smooth 写在根元素上,页内锚点跳转(href="#section-2")自动平滑,不需要 JS 的 scrollIntoView({ behavior: 'smooth' })。代价是”瞬移”变”动画”——长页面连续点目录时有人会晕,无障碍敏感场景可以只在 motion-safe: 下开启(见 060 篇的 motion-safe/motion-reduce)。

3.2 scroll-snap:零 JS 的滚动吸附

滚动吸附三件套:容器定吸附点分布(snap-x/snap-y + snap-mandatory),项目定对齐方式(snap-start/snap-center/snap-end),配合 snap-always 控制逐个停。

html
<!-- 零 JS 图片轮播:横向滚动 + 每张图吸附居中 -->
<div class="flex snap-x snap-mandatory gap-4 overflow-x-auto rounded-xl">
  <img src="/photo-1.jpg" alt="第一张" class="w-4/5 shrink-0 snap-center rounded-lg object-cover" />
  <img src="/photo-2.jpg" alt="第二张" class="w-4/5 shrink-0 snap-center rounded-lg object-cover" />
  <img src="/photo-3.jpg" alt="第三张" class="w-4/5 shrink-0 snap-center rounded-lg object-cover" />
</div>

逐类拆解:flex 让图片排成横向一列;overflow-x-auto 给出横向滚动条(触摸屏上直接滑);snap-x snap-mandatory 声明”横向滚动必须吸附到吸附点”;每张图的 snap-center 把自己定为吸附点、对齐到容器中线;w-4/5 shrink-0 让下一张露出一截——露出的一截是”还能滑”的视觉提示。不写 shrink-0 的话 flex 项目默认可收缩,图片会被压扁而不是溢出滚动,吸附随之失灵——这是 snap 布局的头号翻车点。

3.3 scroll-margin 与 scroll-padding:吸附与锚点的”安全距离”

两个属性解决两种”贴太紧”:

  • scroll-mt-*(scroll-margin)加在目标元素上:锚点跳转或吸附时,给目标上方留出空隙——修”标题跳到吸顶导航后面”的标准解法;
  • scroll-pt-*(scroll-padding)加在滚动容器上:容器自己的”内边距”,吸附点整体让出一段。
html
<!-- 锚点标题:scroll-mt-24 给吸顶导航留 96px,跳转后标题完整可见 -->
<h2 id="install" class="scroll-mt-24 text-xl font-bold">安装步骤</h2>

<!-- 吸附容器:scroll-px-4 让卡片吸附时离容器左缘 16px,不顶死 -->
<div class="flex snap-x snap-mandatory scroll-px-4 overflow-x-auto">
  <div class="snap-start shrink-0 w-64">卡片</div>
</div>

讲解:044 篇 sticky 侧栏实战里 scroll-mt-24 已经出现过一次——“吸附侧栏 + 锚点跳转”组合里它是必需品。区分口诀:margin 是”目标别贴边”,padding 是”容器我要留边”,两者选一即可达成同样效果,团队统一一种写法更好检索。

4. 控件与可达性面板

4.1 accent-color:原生控件的品牌色

accent-* 一行给 checkbox、radio、range、progress 这类保留原生外观的控件换主题色:

html
<!-- 原生勾选框跟上品牌色:选中态的勾与底色全变 -->
<label class="flex items-center gap-2">
  <input type="checkbox" checked class="size-4 accent-emerald-600" />
  <span class="text-sm">记住登录状态</span>
</label>

<!-- 滑杆:拖块与轨道填充色 -->
<input type="range" min="0" max="100" class="w-full accent-blue-600" />

accent-emerald-600 与 055 篇表单示例里的用法同源:它改的是 CSS 的 accent-color 属性,只影响”原生控件的高亮部分”。与 forms 插件或 appearance-none 全自绘的分工:能用原生就用 accent 换色——免费获得键盘操作、无障碍语义与各平台手感;需要跨浏览器像素级一致的设计控件才走自绘(见 100 篇 forms 插件)。appearance-none 是更激进的一步:去掉原生外观后控件变成白纸,一切样式自己画,只建议在有完整自绘方案时使用。

4.2 resize 与 will-change

html
<!-- 可拖角调整大小的文本域,只允许纵向 -->
<textarea class="block w-full resize-y rounded-lg border p-3" rows="4"></textarea>

resize(both/x/y/none)控制 textarea 等元素的拖拽缩放方向;默认 both,表单里常收窄为 resize-y 防止横向撑破布局。

will-change-* 是给浏览器的性能预告:“这个元素马上要动了”。090 篇已有定论——transition 全员可用,will-change 点名使用:只加给确定即将动画的元素,滥加会逼浏览器维护大量闲置合成层。此处归拢备查。

4.3 sr-only:给读屏的”隐形文本”

sr-only 把元素从视觉上藏起(1px、裁剪、绝对定位),但屏幕阅读器照常宣读——它是”图标按钮没文字”的语义补丁:

html
<!-- 纯图标按钮:视觉是 X,读屏听到"关闭" -->
<button class="text-gray-400 hover:text-gray-600" ...>
  <svg class="size-5" aria-hidden="true">...</svg>
  <span class="sr-only">关闭弹窗</span>
</button>

<!-- 055 篇的 tab 骨架同源:sr-only 的 radio 保留键盘可访问性 -->
<input type="radio" class="peer sr-only" />

近亲是 not-sr-only(把 sr-only 的效果反过来),用于”继承了一段 sr-only 样式、但某个断点要显示出来”的场景。装饰性图标记得加 aria-hidden="true"——它自己不读、由 sr-only 的文本代言,两者配合才不重复宣读。

4.4 forced-colors-adjust:高对比度模式下的立场

Windows 高对比度等强制色彩模式会用系统色重绘页面。forced-color-adjust-auto(默认)让浏览器自动映射,forced-color-adjust-none 声明”我的颜色是语义的一部分,别动”——用于品牌色块、状态色点等强语义元素:

html
<!-- 状态色点:高对比度模式下保持原色,语义靠颜色本身传达 -->
<span class="size-2 rounded-full bg-red-500 forced-color-adjust-none"></span>

克制使用:绝大多数内容应该尊重强制色彩模式(那是低视力用户的系统级需求),只有”颜色即语义”的少量元素才声明豁免。

5. 常见错误与对策

错误场景表现原因解决办法
点图标聚焦不了输入框点放大镜没反应图标层拦截了点击图标加 pointer-events-none
假禁用键盘还能聚焦、表单还提交用 pointer-events-none 冒充禁用表单控件用 disabled 属性 + disabled: 变体
轮播拖不动或页面卡住横滑时页面竖向滚动失灵未声明 touch-action容器加 touch-pan-y
snap 失灵图片被压扁吸附没有发生flex 项目默认可收缩项目加 shrink-0 固定宽度
锚点标题被吸顶栏遮住跳转后标题在导航后面目标贴视口顶部目标加 scroll-mt-*
光标手型但点了没反应用户困惑装饰元素误设 cursor-pointer手型只给真可点的元素
读屏念两遍图标按钮“图标 按钮 关闭”图标没加 aria-hidden图标 aria-hidden="true" + 文本 sr-only

6. 动手实践

任务一:零 JS 图片轮播。 用 scroll-snap 实现横向轮播:每张图占容器宽度 85%,相邻图露边提示可滑,滚动必须吸附居中,纵向滚页面不受干扰。提示:三件套 snap-x snap-mandatory + 每项 snap-center shrink-0;别忘了 touch-pan-y 与 scroll-px-*。

任务二:防误触的标签栏。 实现一排可点击的筛选标签:长按标签不弹系统选择菜单、复制防抖(文字不可选)、当前选中标签用品牌色高亮。提示:select-none 管”选不选”,选中态交给状态类(055 篇),原生可访问性靠真实 button 元素。

任务三:原生控件重绘 + 语义补全。 实现一个”音量设置”行:原生 range 滑杆用品牌色、旁边一个静音图标按钮(纯图标、读屏可听懂”静音”)。提示:range 用 accent-*;图标按钮 aria-hidden + sr-only 组合;滑杆要不要禁用交给 disabled 属性而不是 pointer-events。

先自己写,再对照参考实现:

任务一参考实现(零 JS 轮播)
html
<div class="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-px-6 rounded-xl bg-gray-900 p-6">
  <img src="/show-1.jpg" alt="现场照片一" class="w-4/5 shrink-0 snap-center rounded-lg object-cover" />
  <img src="/show-2.jpg" alt="现场照片二" class="w-4/5 shrink-0 snap-center rounded-lg object-cover" />
  <img src="/show-3.jpg" alt="现场照片三" class="w-4/5 shrink-0 snap-center rounded-lg object-cover" />
</div>

自检清单:手指斜着滑,页面竖向滚动是否顺滑(容器可加 touch-pan-y 显式声明);把 shrink-0 删掉,图片是否被压扁、吸附是否失灵(是——这就是收缩与溢出的关系);把 snap-mandatory 换成 snap-proximity,滑动是否变得”可以停在中间”(是——mandatory 强制吸附、proximity 靠近才吸,按手感选)。整个轮播没有一行 JS:吸附是滚动容器的原生能力,JS 只有在要做”圆点指示器”时才需要(读 scrollTop 换算页码)。

任务二参考实现(防误触标签栏)
html
<div class="flex gap-2 overflow-x-auto">
  <button class="select-none shrink-0 rounded-full bg-blue-600 px-4 py-1.5 text-sm text-white" aria-pressed="true">
    全部
  </button>
  <button class="select-none shrink-0 rounded-full border border-gray-300 px-4 py-1.5 text-sm text-gray-600" aria-pressed="false">
    入门
  </button>
  <button class="select-none shrink-0 rounded-full border border-gray-300 px-4 py-1.5 text-sm text-gray-600" aria-pressed="false">
    进阶
  </button>
</div>

select-none 写在每个标签上而不是容器上——容器还有滚动语义,选中行为按元素控制更精确。aria-pressed 把”筛选标签的选中态”翻译给屏幕阅读器(读作”已按下”),这是用原生 button 而不是 div + onclick 的红利:键盘焦点、Enter 触发、语义播报全部免费。横向放不下时容器 overflow-x-auto + 标签 shrink-0,就得到了 3.2 节同款的横向滚动区——可以顺手加 snap-x 让标签吸附,但筛选标签逐个吸附反而拖手感,此处不加是对的。

任务三参考实现(音量行)
html
<div class="flex items-center gap-4 rounded-xl border border-gray-200 p-4">
  <button class="rounded-full p-2 text-gray-500 hover:bg-gray-100" aria-pressed="false">
    <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
            d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
    </svg>
    <span class="sr-only">静音</span>
  </button>
  <input type="range" min="0" max="100" value="70" class="h-1.5 w-full accent-blue-600" aria-label="音量" />
  <span class="w-10 text-right text-sm text-gray-500" aria-hidden="true">70</span>
</div>

三个可达性细节:图标 svg 加 aria-hidden="true"、按钮内 sr-only 文本”静音”,读屏用户听到的是一个完整按钮名而不是空白;range 自身有可访问名(aria-label="音量"),滑杆值变化读屏会宣读;右侧数字加 aria-hidden 防止读屏把”滑杆 70”和”70”念两遍。accent-blue-600 一行完成原生滑杆的品牌色——试着把它删掉,滑杆立刻退回系统默认蓝灰,这行就是”原生控件重绘”的全部成本。若产品要求完全自定义滑杆外观,才升级到 appearance-none 自绘方案,那是一个组件工程而不是一行类的事。

7. 一句话记忆

交互族是四张开关面板:指针(cursor 立承诺、pointer-events 让路)、选择与触摸(select-none 防误触、touch-pan-y 分方向)、滚动(snap 三件套零 JS 轮播、scroll-mt 给锚点让位)、控件与可达性(accent 换原生色、sr-only 给读屏代言、forced-colors 少用)。

8. 相关阅读