从设计稿到代码:打造无可挑剔的前端多选下拉组件

如果你接手过一个“看起来很简单,但怎么做都差一口气”的前端需求,那你一定懂我为什么把一个组件的代号起成impeccable。

这个词的意思是“无可挑剔的”。我用它命名的是一个筛选面板里的多选下拉组件。产品经理的原话是:“就是普通的筛选项,你做精致一点。”但真正动手后我才发现,所谓“精致”,意味着每一个像素都要有依据,每一次交互都要有反馈,每一条样式规则都要禁得起追问。这不是一个两周迭代里随手写的下拉框,而是一套可以被其他业务线复用的、开箱即得的高质量前端组件。

这篇文章把我从设计稿分析到组件封装、从视觉细节到动效手感的完整过程记录下来。包括我为什么选择原生 JavaScript 加自定义属性,而不是直接引一个组件库;也包含我在像素层、交互层、无障碍层面踩过的坑和最终给出的解决办法。适合正在做组件库、设计系统落地,或者单纯想把页面质感往上提一个档次的同学参考。

1. 内容整体设计与思路拆解

1.1 “挑剔”是需求还是成本:项目定位解析

很多团队做前端组件,第一反应是“能用就行,反正后面要改”。但 impeccable 这个项目从一开始就定了一个原则:需求可以砍,交付的每一部分必须完整。

我拿到的原始需求只有一句话:“筛选区域需要一个支持多选的下拉,选中项以标签展示,支持清空。”如果按常规思路,这就是一个下拉面板加一组 checkbox,再加一个“确定”按钮。但仔细拆解后,实际涉及的细节远超这个范围:

  • 下拉按钮在未选中和已选中状态下,视觉重量应不应该一样?
  • 面板展开时,动画是自上而下滑出,还是透明度渐变?
  • 选中某一项后,面板要不要立即关闭?还是像移动端那样点一次收起、多点几下?
  • 键盘用户怎么选择选项?呼出菜单的快捷键是什么?
  • 标签超出容器宽度时,是换行还是省略显示?省略的话,怎么查看完整内容?

这些问题每一个单独拎出来都不难,但放到一起就成为了一整套交互规范。impeccable 的核心价值不是那个最终做出来的下拉框,而是这套规范本身。后续任何团队要在这个项目里新增一个“日期筛选”“区域筛选”,都可以直接复用同一套设计语言和代码基础。

1.2 技术选型的权衡:为什么不用组件库

市面上随手可用的多选组件库不少,Element 的 el-select、Ant Design 的 Select,功能都很完整。但我还是选择用原生 JavaScript 加 CSS 自定义属性去实现。理由有两条:

第一,为了控制包体和样式权重。引入一个完整组件库,即使是按需加载,也会带来样式覆盖成本。业务方给出的视觉稿有大量定制细节,比如标签的圆角半径、面板阴影的透明度、动画的缓动曲线,这些在原生实现里只是几行 CSS,但在组件库里往往要写一堆覆盖样式,还不一定能覆盖干净。

第二,为了可解释性。这个项目后续要交接给刚入行的同事维护。原生实现里每一步逻辑都是显性代码,没有魔法。我希望维护者打开源码时,能清楚知道“为什么这里要 4px”“为什么动画时长是 160ms”,而不是在一个大型抽象组件里翻文档。

第三,研究下来 UI 层的状态并不复杂,只是状态之间的联动多。用原生 JavaScript 组织一个简单的状态对象,配合自定义事件来通信,反而比引入 Redux、Zustand 这类状态库更清晰。组件内部的状态只有“未展开/已展开”“选中集合”这两个维度的变化。

整体架构分为三层:结构层(HTML 的语义化结构)、表现层(CSS 变量驱动的样式体系)、行为层(JavaScript 的状态管理与事件绑定)。三层之间用>:root { --impeccable-unit: 4px; --impeccable-space-xs: calc(var(--impeccable-unit) * 1); --impeccable-space-sm: calc(var(--impeccable-unit) * 2); --impeccable-space-md: calc(var(--impeccable-unit) * 3); --impeccable-space-lg: calc(var(--impeccable-unit) * 4); --impeccable-radius: 6px; --impeccable-radius-sm: 4px; }

这里有一个细节要注意:圆角我并没有强行用 4px 栅格。视觉稿里的按钮圆角是 6px,虽然不符合 4 的倍数,但这是品牌视觉规范的一部分,属于“设计意图”而不是“随手填的数值”。代码的任务是忠实还原设计,而不是反过来用数学规则去修正设计。所以我保留了 6px,但在变量命名上明确标注了它是“品牌圆角”,避免后人误以为是计算错误。

2.2 字体、行高与对齐的“隐形陷阱”

文字是界面上最容易被忽略、也最容易出问题的部分。多选下拉组件的核心交互对象就是文字,所以字体细节成了 impeccable 项目里花时间最多的地方之一。

先说字号。按钮文字、选项文字、标签文字,我都采用了 14px。按 Windows 平台的显示习惯,14px 的汉字笔画表现比较稳定,小于 14px 时部分描边偏细的字体会出现发虚,这在设计稿上压根看不出来。

再说行高。视觉稿中所有文字的单行行高标注为 20px。这里暗藏一个问题:如果直接设line-height: 20px,当文字换行成两行时,总高度不会是 40px,因为某些浏览器的行框上下还带半行距。稳妥的做法是把行高和纵向内边距分开控制。选项容器用display: flex加align-items: center,文字行的行高设为 1.5,由容器高度来决定整体纵向位置。这样即使选项内容是两行长文本,上下间距依然是可控的。

文字对齐上最容易踩的坑是“图标和文字的高度差”。下拉箭头图标如果宽度和高度都是 16px,放在 14px 文字旁边时,视觉上会往下偏移约 1px。因为图标的最低点是它的像素边缘,而文字的最低点是基线以下,两者的“视觉最低点”不一样。我的解法是把图标放在一个 20px 高的容器里,再用flex居中对齐,让图标矩阵的几何中心和文字块的中心重合。

2.3 状态、边界与更细的“微交互”

一个无可挑剔的组件,不能只有静态颜值,它的每个状态都要有明确的反馈。impeccable 定义了这样一组状态矩阵:

状态触发条件视觉反馈
默认态鼠标不在组件上灰色边框,白底
悬停态鼠标悬停在按钮区域边框加深,阴影轻微浮现
聚焦态键盘 Tab 聚焦到按钮2px 品牌色外圈,偏移 2px
展开态面板打开边框和阴影合并,下角圆角消失
选中态选中某选项选项背景变为浅品牌色,左侧出现对勾
禁用态业务侧传回禁用字段透明度 50%,不响应事件

这些状态里,focus 样式的细节最容易被忽略。很多团队做焦点样式时直接写一个outline,但 outline 无法控制圆角,在浏览器默认样式下会比较突兀。我的方案是用box-shadow模拟焦点环,同时保留 2px 的偏移量,避免紧贴按钮边缘产生糊在一起的感觉:

.select-button:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--impeccable-focus-ring); }

再补充一个实用细节:标签的关闭按钮本来只有 14px 大小,属于比较小的点击目标。我给它加了一个隐形扩展区,用伪元素把可点击范围扩大到 20px,但视觉尺寸保持不变。这个小改动对鼠标用户影响不大,对触屏用户和老人用户特别友好。

2.4 动效到底该如何设计才不“腻”

动效是判断一个组件“高级感”的重要分水岭。很多下拉面板喜欢做从顶部往下推出的动画,或者左右滑入,其实这类位移动画在空间上跟“下拉”的语义一致,但对屏幕阅读器用户来说,位移过大会增加认知负担。我的方案是:面板默认透明度 0,展开时透明度从 0 到 1,同时施加一个非常轻微的 4px 上移归位效果。整个过程 160ms,缓动曲线使用cubic-bezier(0.4, 0, 0.2, 1),这是 Material Design 的标准曲线,体感上偏沉稳,不飘。

为什么是 160ms 而不是 300ms?因为下拉面板是高频交互组件,用户一天可能要开合几十次。动画太长会拖慢操作节奏,太短又会显得生硬。160ms 恰好处于视觉能感知到“有一个过渡”的最低阈值附近。

另外,我特意给动效写了一个降级规则:如果用户系统开启了“减少动态效果”偏好(prefers-reduced-motion: reduce),那么面板直接切换透明度,不做位移。这个问题很多资深开发者都会漏,但对晕动症用户来说,无意义的位移动画可能引发不适。一个号称 impeccable 的组件,这方面的关怀不能缺席。

3. 实操过程与核心环节实现

3.1 阶段一:从设计稿反推结构

视觉稿是一张静态图,但里面暗含了页面结构信息。我第一步不是写 HTML,而是把设计稿拆成“最小语义结构”。

下拉按钮的本质是一个按钮,不能做成div。按钮里包含两段内容:左侧是当前选中态的摘要文字(未选中时是 placeholder),右侧是清空按钮和展开箭头。这里的清空按钮和展开箭头在视觉上是并列的,但功能完全不同,所以结构上必须是独立节点。

下拉面板在 HTML 上的语义是列表,选项就是li。多选意味着选项本身不是互斥的,不能用原生单选radio的逻辑去套。不过我也没选择直接用原生checkbox,因为原生框的视觉样式和完全自定义之间需要大量覆盖,而组件的定位是高度可定制。权衡之后,我用li加aria-checked属性来模拟多选语义,兼顾语义化和视觉自由度。

面板从 display 隐藏切换到展开时,用 JavaScript 控制hidden布尔属性,而不是直接切换display样式。配合自定义事件和requestAnimationFrame,保证过渡动画从正确的状态开始。

这个阶段的产出是一份带注释的 HTML 骨架,每一个><div class="impeccable">.impeccable { --impeccable-primary: #2563eb; --impeccable-primary-soft: rgba(37, 99, 235, 0.1); --impeccable-border: #d1d5db; --impeccable-text: #111827; --impeccable-text-muted: #6b7280; --impeccable-bg: #ffffff; }

--impeccable-primary-soft这个变量是专门为选中态准备的。如果只给rgba(37, 99, 235, 0.1)一个固定值,不同主色主题下透明度不同,视觉效果不一致。我把主色拆成 RGB 三通道的字符串变量,再用rgba(var(--impeccable-primary-rgb), 0.1)的方式去拼,实现更灵活的派生色:

:root { --impeccable-primary-rgb: 37, 99, 235; } .option--selected { background-color: rgba(var(--impeccable-primary-rgb), 0.08); }

这个技巧在主题化设计里非常实用。主色可以微调,派生色会自动跟着变,不需要维护多套颜色变量。

样式实现的另一个细节是“边框占位”。下拉按钮在边框和阴影之间如果处理不好,展开后视觉会“跳”一下。我让按钮在未展开时有 1px 边框和轻微阴影,展开时阴影消失、边框变为主色,同时面板顶部和按钮底部无缝衔接:

.impeccable--open .impeccable__button { border-bottom-left-radius: 0; border-bottom-right-radius: 0; box-shadow: none; }

这样面板打开时,按钮和面板之间没有缝隙,像一个整体,关闭后恢复原有阴影,过渡很自然。

3.3 阶段三:状态管理与键盘交互

我把组件的行为层设计成一个微小但完整的状态机。核心状态只有两个:isOpen和selectedValues。所有 UI 更新都由状态变化驱动,避免出现“直接操作 DOM”的碎片化逻辑。

const state = { isOpen: false, selectedValues: new Set(), currentIndex: -1, }; function emitChange() { rootElement.dispatchEvent(new CustomEvent('impeccable:change', { detail: { values: [...state.selectedValues] }, })); }

更新 UI 的方法都遵循一套原则:“根据状态算出结果,再一次性写入 DOM”。比如选中某个选项时,先改 state,再重绘所有选项的选中态和按钮标签文字,而不是在事件回调里东改一处西改一处。这样做的好处是逻辑清晰,也对后续接入框架友好,因为状态层和视图层边界分明。

键盘交互是无障碍的核心。我完整实现了 WAI-ARIA 中 listbox 的键盘模式:

  • Enter或Space:开合面板;面板内确认当前焦点选项
  • ArrowDown/ArrowUp:移动焦点到下一项/上一项
  • Home/End:跳到第一项/最后一项
  • Escape:关闭面板,焦点回到按钮
  • Tab:关闭面板,移动到下一个页面元素

这里的焦点管理是多数组件库都做得稀烂的地方。很多实现里键盘上下移动只是改了高亮,没改真实焦点,导致屏幕阅读器读到的焦点位置和视觉高亮位置不一致。我的实现里用tabindex="-1"管理选项焦点,配对aria-activedescendant,让读屏软件准确知道当前“活动项”是哪一个。

还踩过一个比较隐晦的坑:当面板关闭后再打开,焦点应该恢复到之前记忆的选项位置,而不是每次从头开始。所以 state 里单独记录了一个currentIndex,关闭面板时不清零,下次打开时从这个位置继续。

3.4 阶段四:标签交互与边界情况处理

选中项以标签形式展示在按钮区域。标签本身要支持单个删除,比如选了“上海”“北京”两个城市,用户点“上海”标签上的 ×,应该只删除“上海”。

标签的关闭按钮和整个组件自带的“清空全部”按钮,是两种完全不同的操作,数据上一个是删除单个,一个是清空集合。我给标签上的按钮绑定click事件时,必须阻止事件冒泡。不然用户点标签的 ×,事件会一路冒泡到组件最外层,触发面板展开。

另一个边界是标签数量过多时的表现。我选择了“超出宽度自动隐藏”的方案:区域内最多显示一个“+N”的汇总标签,点击这个汇总标签会弹出所有标签的浮层。这个浮层只展示,不参与操作,所以它在组件状态机里不占任何权重,只是在视觉上补充了完整性。

清空全部按钮默认只有一句话“全部”或者“清空”,但可访问性上需要给读屏软件一个更明确的描述。我用aria-label覆盖了按钮的可见文本:“清空所有筛选项”。

另外必须注意:多选组件的“清空”操作,到底是清空后再展示 placeholder,还是保留一个“已清空”状态?我的做法是清空后立即重新展示 placeholder,同时把焦点留在清空按钮上,方便用户误操作后立刻重新选择。这比把焦点移动到页面别处更符合使用直觉。

4. 常见问题与排查技巧实录

4.1 视觉差之毫厘:选中项的“背景溢出”

我第一次写完选中态样式时,发现选中项的背景色不是一个完整的圆角矩形,右下角会溢出到面板边缘外面。排查后发现是因为li自身没有设置圆角,而背景色直接作用在li上,面板容器虽然有圆角,但overflow没有设置为hidden。

后来的解法不是加overflow: hidden,因为那会把下拉面板的影子裁切掉。我改成给li自己的选中态加圆角,同时用内边距和面板内部间距保持一致。这个教训后来被我写进了团队的卡片式列表组件规范里:凡是带独立背景色的条目,自己必须持有圆角属性,不能依赖容器裁切。

4.2 高 DPI 下的 1px 问题

展开按钮的底部边框在部分高分屏上看起来是 2px,而不是 1px。原因是设计稿里的 1px 在实际渲染中对应设备的物理像素,而普通屏是 1 物理像素,高分屏是 2~3 物理像素。虽然现代浏览器对标准 1px 边框做了适配,但自定义阴影和圆角边缘的组合仍可能出现视觉粗细不均。

我的排查方式是直接打开开发者工具,把border-bottom-width临时改成0.5px看效果。后来我使用的是硬件像素感知判断,不过更稳妥的方案是直接放弃物理边框,改用box-shadow: 0 1px 0 0模拟 1px 分割线,阴影在 DPI 缩放下的表现比边框更稳定。这个技巧对弹窗、面板、分隔线等场景都适用。

4.3 动画与焦点管理的冲突

有一版实现里,我用 CSS 过渡做面板淡入,但发现在动画进行中按下Tab键,焦点会跳到不可见的选项上,读屏软件也会读出还没渲染完成的选项。这是因为hidden属性移除后,浏览器立刻认为元素可见、可聚焦,但透明度还在 0。

修复思路是让动画结束后才允许面板获得焦点。具体做法是监听transitionend事件,动画完成后再把aria-hidden置为 false。但transitionend在用户系统开启“减少动态效果”时可能不会触发,所以还必须做一次能力检测,走降级逻辑。这个问题的核心认知是:视觉可见性和可聚焦性是两种状态,必须分别管理。

4.4 疑难问题自查清单

现象可能原因排查与修复
点击按钮外不能关闭面板全局点击监听未绑定在 document 上绑定 mousedown 事件,判断点击区域是否在组件根节点内
选项选中后按钮文本不更新重绘函数遗漏了 label 节点统一走 render 方法,不单独在某处赋值
清空按钮点击后面板展开事件冒泡清空按钮上加e.stopPropagation()
键盘选择不生效选项没有被正确聚焦检查aria-activedescendant指向的 id 是否存在
标签删除后选项仍显示选中Set 更新后未触发重绘确认删除逻辑传入的是值而非索引,Set 的 delete 是值匹配
聚焦环在鼠标点击时出现未区分键盘和鼠标焦点使用:focus-visible而不是:focus
多语言场景下“全部”文案超长没有考虑文案长度按钮 label 区域使用min-width: 0加text-overflow: ellipsis

这张表是我在连续两次版本迭代里积累的实战问题集。很多问题在验收时根本不会曝光,但真实用户一用就暴露。写好这类组件的信心,很大程度上来自把这些边界情况提前想清楚,而不是等 bug 单找上门。

5. 顺手加了但没想到价值极高的两个细节

仔细复盘下来,有两个细节跟视觉无关,却在用户反馈里得到了最多的正面评价。唯一可惜的是它们太容易被忽略,这里专门记一笔。

第一个是“选中项顺序的记忆”。很多下拉多选组件在重开面板时,选项排序会回到默认列表顺序。用户如果辛辛苦苦选了十个城市,再次打开时想取消其中第四个,发现顺序全变了,根本找不到刚才的位置。我在每次选中时给 Set 里的值追加时间戳排序,重开面板时按选中顺序渲染,取消时也按这个顺序定位,这个体验优化几乎零成本,但感知非常强。

第二个是“离屏克隆”。当按钮区域的标签溢出隐藏,我并不是简单截断,而是把溢出的标签克隆到一个绝对定位的预览浮层里,浮层的样式和主区域的标签完全一致。用户 hover 到省略号上时看到的是完整的、可读的标签列表。这个实现本来是为了满足产品经理的“标签尽量展示完整”需求,后来在给设计师验收时,对方还以为是本来就规划好的功能。

最后

一个号称 impeccable 的组件,做完之后我给它的定义并不是“零 bug”,而是“每个状态都有答案”。这个过程中最深的体会是:视觉上的“无可挑剔”,背后通常是交互逻辑上的“事无巨细”。很多细节不需要天赋,只需要足够的耐心和一套可复用的排查方法。

如果你也要做一个类似的定制组件,我的建议是先别急着写代码。花半天时间把所有状态列出来,把所有边界情况问一遍,想清楚每一个“如果”。这个前期投入,会在后续开发和维护里以十倍回报补回来。组件库的成熟度,从来不是看它写了多少行代码,而是看它替用户挡掉了多少个坑。