写在前面
胶囊分段控件,一个状态源驱动滑块和数据面板,键盘方向键白送可达性。
今天的组件,是现代编辑器里最经典的一种交互:命令面板(Command Palette)。
用过VS Code的Ctrl+P、用过Spotlight的Cmd+Space、用过Figma的Ctrl+/,都会记得那种体验:输入框一聚焦,一个悬浮面板从下方浮出,分组命令列表、快捷键标签、首项高亮、底部渐隐。
看起来复杂,但核心其实只有两个属性::focus-within负责开合,mask负责渐隐。
也要诚实说清楚:命令面板有三件事,纯CSS做不了。
一是Esc关闭,二是输入过滤,三是回车执行。
这三件都需要监听键盘事件,CSS拿不到。
本篇demo止步于开合与展示,这三件真功能,留给变式挑战里的JS。
这不是妥协,是边界。知道边界在哪,才知道什么时候该用JS。
一、先看效果

深色编辑器界面,正中一枚搜索框。
输入框左侧一个放大镜图标,中间占位文字是「搜索文档、组件或命令...」,右侧挂着一个⌘K徽标。
点击或Tab 聚焦的瞬间,命令面板从输入框下方浮出:
面板分两组。操作组有三条:新建文档、插入代码块、导出为Markdown。导航组有两条:回到首页、打开设置。
每条命令:左侧一个小色块,中间命令名,右侧快捷键标签。首项淡蓝高亮,左边有一道蓝色竖条。
面板底部被mask渐隐进背景,暗示列表还有内容。
点面板外任意处,自动收回。
怎么跑起来:
- 新建一个
demo.html - 把下面第二节的完整代码粘进去
- 双击用浏览器打开
截图党提示:建议做Before / After对比图。左半放浏览器默认
<datalist>下拉截图,右半放面板弹出的GIF。反差即点击理由。
二、完整代码(复制即用)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Day 33 · 命令面板</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height:100%; }
body { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:34px;
overflow:hidden; background:#0f141a; font-family:"PingFang SC", sans-serif; color:#e6edf3; }
.head { text-align:center; }
.head h1 { font-size:20px; letter-spacing:.3em; font-weight:600; }
.head p { margin-top:10px; font:400 11px/1 "SF Mono", Consolas, monospace;
letter-spacing:.3em; color:#5b6878; }
.wrap { position:relative; width:min(520px, 92vw); }
/* ------ 触发输入框 ------ */
.search { display:flex; align-items:center; gap:11px; padding:14px 16px;
border-radius:14px; background:#161d26; border:1px solid rgba(255,255,255,.09);
transition:border-color .25s, box-shadow .25s; }
.wrap:focus-within .search { border-color:rgba(125,211,252,.45);
box-shadow:0 0 0 3px rgba(125,211,252,.12), 0 20px 50px rgba(0,0,0,.45); }
.search svg { flex:none; }
.search input { flex:1; background:transparent; border:0; outline:0;
color:#e6edf3; font-size:14px; font-family:inherit; }
.search input::placeholder { color:#5b6878; }
.search kbd { flex:none; }
kbd { font:400 10px/1 "SF Mono", Consolas, monospace; color:#8b97a8;
background:#0f141a; border:1px solid rgba(255,255,255,.14);
border-bottom-width:2px; border-radius:5px; padding:3px 6px; }
/* ------ 面板:focus-within 开合,底部 mask 渐隐 ------ */
.panel { position:absolute; left:0; right:0; top:calc(100% + 10px);
border-radius:14px; background:#161d26; border:1px solid rgba(255,255,255,.09);
box-shadow:0 30px 70px rgba(0,0,0,.5);
overflow:hidden; padding-bottom:26px;
-webkit-mask:linear-gradient(#000 76%, transparent);
mask:linear-gradient(#000 76%, transparent);
opacity:0; visibility:hidden; transform:translateY(10px) scale(.99);
transition:opacity .22s ease, transform .22s ease, visibility 0s linear .22s;
pointer-events:none; }
.wrap:focus-within .panel { opacity:1; visibility:visible;
transform:translateY(0) scale(1); transition:opacity .22s ease, transform .22s ease;
pointer-events:auto; }
.group { padding:12px 8px 2px; }
.group-label { padding:0 10px 6px; font:400 9px/1 "SF Mono", Consolas, monospace;
letter-spacing:.28em; color:#5b6878; text-transform:uppercase; }
.cmd { display:flex; align-items:center; gap:11px; padding:10px 12px;
border-radius:9px; font-size:13px; color:#c7d1de; cursor:pointer; }
.cmd i { width:8px; height:8px; border-radius:3px; background:var(--c,#7ab8ff); flex:none; }
.cmd kbd { margin-left:auto; }
.cmd.hot { background:rgba(125,211,252,.09);
box-shadow:inset 2px 0 0 #7ab8ff; }
.cmd:hover { background:rgba(255,255,255,.05); }
.foot { position:absolute; left:0; right:0; bottom:8px; text-align:center;
font:400 9px/1 "SF Mono", Consolas, monospace; letter-spacing:.26em; color:#39434f; }
.hint { font:400 10px/1 "SF Mono", Consolas, monospace; letter-spacing:.3em; color:#39434f; }
.tag { position:fixed; right:18px; bottom:14px; font:400 10px/1 "SF Mono", Consolas, monospace;
letter-spacing:.28em; color:rgba(230,237,243,.22); }
</style>
</head>
<body>
<div class="head">
<h1>COMMAND PALETTE</h1>
<p>FOCUS TO OPEN · GROUPED · MASKED</p>
</div>
<div class="wrap">
<div class="search">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#5b6878" stroke-width="2.4" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>
<input type="text" placeholder="搜索文档、组件或命令...">
<kbd>⌘K</kbd>
</div>
<div class="panel">
<div class="group">
<div class="group-label">操作</div>
<div class="cmd hot"><i style="--c:#7ab8ff"></i>新建文档<kbd>⌘N</kbd></div>
<div class="cmd"><i style="--c:#5cc9b4"></i>插入代码块<kbd>⌘⇧C</kbd></div>
<div class="cmd"><i style="--c:#ffd479"></i>导出为 Markdown<kbd>⌘E</kbd></div>
</div>
<div class="group">
<div class="group-label">导航</div>
<div class="cmd"><i style="--c:#b9a7ff"></i>回到首页<kbd>G H</kbd></div>
<div class="cmd"><i style="--c:#ffc2cf"></i>打开设置<kbd>G S</kbd></div>
</div>
<div class="foot">6 条结果 · ENTER 打开 · 底部渐隐由 MASK 完成</div>
</div>
</div>
<div class="hint">CLICK OR TAB INTO THE FIELD · ZERO JS</div>
<div class="tag">每天一个惊艳 CSS · DAY 33</div>
</body>
</html>
三、三个核心技法拆解
今天这三个技法,从开合逻辑到微材质到视觉暗示,一层一层往上走。
技法 1::focus-within的天然关闭
面板开合只有一个开关:
css
.wrap:focus-within .panel {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.wrap是包住输入框和面板的容器。:focus-within的含义是:容器自己或者任意后代持有焦点时,选择器成立。
三种情况:
输入框聚焦,:focus-within成立,面板开。点面板里的某一行,焦点还在wrap内,面板保持开。点面板外任意处,焦点丢失,:focus-within失效,面板自动收。
纯CSS版命令面板最优雅的地方是,关闭不需要写任何代码。焦点就是开关。
这是mega菜单同一招的又一次应用。hover + focus-within双通道,今天是纯focus-within。为什么今天不用hover?
因为命令面板是点击打开的,不是悬停打开的。用hover会让面板在鼠标滑过时误触发,观感很差。
focus-within还有一个隐性红利:它天然处理了点击面板内部不关闭的问题。用JS做命令面板时,需要给面板加一个mousedown事件阻止冒泡,否则点击面板内部会触发外部点击关闭。用focus-within,这个问题不存在,因为焦点还在wrap内。
纯CSS有时候不是偷懒,是用对了原语。
技法 2:kbd语义样式
快捷键标签虽然只有几个字,但它决定了一个组件像不像软件。
css
kbd {
font: 400 10px/1 "SF Mono", Consolas, monospace;
color: #8b97a8;
background: #0f141a;
border: 1px solid rgba(255,255,255,.14);
border-bottom-width: 2px;
border-radius: 5px;
padding: 3px 6px;
}
四个细节:
一是等宽字体,SF Mono或Consolas。快捷键是代码语言的一部分,等宽字看起来才像。
二是双层边框,border-bottom-width: 2px比其他三边厚。模拟按键的立体感,按键底部比顶部厚,像真的能按下去。
三是深底浅字,background: #0f141a配color: #8b97a8。和主界面形成对比,但不过分突兀。
四是小圆角,border-radius: 5px。方方正正的按键太硬,稍微圆一点更亲和一些。
这类微材质在组件惊艳化里性价比极高。它几乎不占代码量,但一眼就能让组件从像目录变成像软件。
用过Linear、用过Raycast、用过Vercel Dashboard,都会注意到它们对kbd的处理都很讲究。这不是装饰,是语义。
顺带说一句,<kbd>是HTML的语义标签,专门用来标记键盘输入。用它比用<span>更正确,屏幕阅读器会把它读成按键。
用对标签是免费的可访问性。
技法 3:mask渐隐 = 还有更多的暗示
面板底部的渐隐:
css
.panel {
overflow: hidden;
padding-bottom: 26px;
-webkit-mask: linear-gradient(#000 76%, transparent);
mask: linear-gradient(#000 76%, transparent);
}
mask-image是给元素自身打遮罩。linear-gradient(#000 76%, transparent)的意思是:从顶部到76%的位置完全不透明,从76%到底部逐渐透明。
效果是列表末尾融进背景,暗示下面还有内容。
为什么要做这个渐隐?
命令面板通常有十几条命令,一屏装不下。如果不做渐隐,直接overflow: hidden硬切,观感像列表被截断了。做了渐隐,观感像列表还在继续,只是暂时看不到。
这是一个视觉暗示,告诉用户还有更多。
比加一条渐变遮罩div更好的地方是:mask直接作用于元素自身像素,不占布局、不用定位。
如果用渐变遮罩div,需要绝对定位在面板底部,高度要算好,z-index要管好,还要处理pointer-events。用mask,一行属性搞定。
雪山雪线、滚动叙事封面用过的同一招。mask是视觉裁切的原语,能省掉大量的定位代码。
注意一个坑:mask会把box-shadow一起裁掉。面板的阴影画在元素外,mask渐隐会切掉底部阴影。
补救方式有两个:一是阴影挪到外层容器上,二是接受底部无影。
深色主题下阴影本来就不明显,接受也能用。
四、踩坑位(我全踩过,你直接避)
① Esc关闭、输入过滤、回车执行
这三件是命令面板的真功能,全部需要监听键盘事件。纯CSS的边界就在这里。
本篇demo诚实止步于开合与展示。变式挑战给输入过滤的JS。
这不是偷懒,是边界。知道边界在哪,才知道什么时候该用JS。命令面板如果只做展示,纯CSS 就够;如果要做工具,就必须用JS。
② 面板打开时页面还能滚
全屏版命令面板需要锁滚动。
本篇是锚定下拉形态,面板贴在输入框下方,不是全屏遮罩,所以不存在这个问题。
做成全屏遮罩版时,要补overflow: hidden到body上,否则背景会跟着滚。
顺带说一句,全屏遮罩版还需要处理点击遮罩关闭。纯CSS里可以用:target或者一个隐藏的radio状态机,但代码量会翻几倍。
锚定下拉版是最简洁的命令面板形态。
③ 首项高亮用.hot硬编码
本篇demo里,第一条命令带.hot类,表示当前高亮项。
但真实产品的当前项跟随键盘移动,上下键按一下,高亮就从第一行跳到第二行。纯CSS无法动态换行,这是第二个诚实边界。
如果要做真键盘导航,需要JS监听keydown,用索引切换类。或者用:focus-visible让当前焦点的那一行自己高亮,但那样需要给每一行加tabindex,键盘用户按Tab走一遍,和上下键的体验不一样。
权衡下来,纯CSS版就用硬编码高亮,够用了。
④ mask把box-shadow一起裁了
前面说过,mask渐隐会切掉底部阴影。
补救:阴影挪到外层容器,或者接受底部无影。
深色主题下影响不大,浅色主题下会有一点割裂感。做浅色版时建议用外层容器方案。
⑤ 点面板内部会收起吗
不会。
因为焦点仍在wrap 内,:focus-within依然成立。这正是focus-within的红利。
但如果面板里的行是<a href="#">,点击后焦点可能跳走导致面板闪收。
演示行用<div>最稳。真实产品里用<button>更合适,但<button>也会触发焦点跳走,所以纯CSS版通常用<div> + cursor: pointer假装按钮。
五、变式挑战(10--30分钟,动手才有收获)
任务:输入过滤。
给input挂input事件,遍历.cmd行,按关键词display: none,再按可见数更新组标题。命令面板立刻从展示变成工具。
js
inp.addEventListener('input', () => {
const q = inp.value.toLowerCase();
document.querySelectorAll('.cmd').forEach(r =>
r.style.display = r.textContent.toLowerCase().includes(q) ? '' : 'none');
});
JS,不多不少。
这是本篇唯一一次开戒,也是命令面板从像软件变成是软件的分界线。
真正的命令面板还需要处理:
一是组标题随可见数更新。组内一条命令都没有时,组标题也要隐藏。
二是结果数为0时显示空状态。
三是首项高亮跟随键盘移动。
四是回车执行当前项。
这些都加上,代码量会到60--80行。已经不能算微量JS了,属于完整组件的范畴。
如果只做搜索过滤,8行够用。
做完发评论区。重构征集第一轮持续进行,你的旧导航截图仍在收。
六、小结
今天这一篇里藏着5个通用技能点:
| 技能 | 适用场景 |
|---|---|
:focus-within天然开关 |
所有聚焦触发的浮层 |
kbd语义标签 + 微材质 |
所有快捷键展示 |
mask渐隐做视觉暗示 |
所有列表末尾的还有更多 |
translateY + scale微入场 |
所有浮层的出现动画 |
opacity + visibility + pointer-events三重隐藏 |
所有隐藏但不占位的元素 |
最值得记住的三条。
第一,:focus-within是浮层开合的原语。
聚焦开、失焦收,一行CSS搞定。点内部不关闭、点外部自动关,都是:focus-within天然处理的。
比JS方案少写几十行,还不容易出bug。
第二,kbd语义标签配微材质,是组件像软件的分界线。
等宽字体、双层边框、深底浅字、小圆角,四个细节。
代码量极少,观感提升极大。这是组件惊艳化的高性价比点。
第三,mask渐隐比加遮罩div更简洁。
直接作用于元素自身像素,不占布局、不用定位。
雪山、滚动叙事、命令面板,同一个原语的三种应用。
记住mask会把box-shadow一起裁掉,需要时把阴影挪到外层容器。
如果这一篇对你有帮助,三连支持一下这个专栏 🙏
点赞 = 更新加速,收藏 = 随时复制代码,评论 = 你想看什么效果,我排进日程。
本专栏所有demo,复制即用,随便商用。
目标更新:不断更。