【每天一个CSS | Day33】聚焦即弹出的命令面板

写在前面

胶囊分段控件,一个状态源驱动滑块和数据面板,键盘方向键白送可达性。

今天的组件,是现代编辑器里最经典的一种交互:命令面板(Command Palette)。

用过VS Code的Ctrl+P、用过Spotlight的Cmd+Space、用过Figma的Ctrl+/,都会记得那种体验:输入框一聚焦,一个悬浮面板从下方浮出,分组命令列表、快捷键标签、首项高亮、底部渐隐。

看起来复杂,但核心其实只有两个属性::focus-within负责开合,mask负责渐隐。

也要诚实说清楚:命令面板有三件事,纯CSS做不了。

一是Esc关闭,二是输入过滤,三是回车执行。

这三件都需要监听键盘事件,CSS拿不到。

本篇demo止步于开合与展示,这三件真功能,留给变式挑战里的JS。

这不是妥协,是边界。知道边界在哪,才知道什么时候该用JS。


一、先看效果

深色编辑器界面,正中一枚搜索框。

输入框左侧一个放大镜图标,中间占位文字是「搜索文档、组件或命令...」,右侧挂着一个⌘K徽标。

点击或Tab 聚焦的瞬间,命令面板从输入框下方浮出:

面板分两组。操作组有三条:新建文档、插入代码块、导出为Markdown。导航组有两条:回到首页、打开设置。

每条命令:左侧一个小色块,中间命令名,右侧快捷键标签。首项淡蓝高亮,左边有一道蓝色竖条。

面板底部被mask渐隐进背景,暗示列表还有内容。

点面板外任意处,自动收回。

怎么跑起来:

  1. 新建一个demo.html
  2. 把下面第二节的完整代码粘进去
  3. 双击用浏览器打开

截图党提示:建议做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,复制即用,随便商用。

目标更新:不断更。

相关推荐
燕落南枝1 小时前
CSS 用户行为伪类::hover 与 :focus 详解
前端·css
风骏时光牛马1 小时前
业务基础信息管理库
前端
zyxqyy&∞1 小时前
sqli-labs-布尔盲注 (Less-8),时间盲注 (Less-9~10)
前端
m0_敲代码的彭于晏1688 小时前
Vercel 前端部署平台深度评测
前端
怕浪猫10 小时前
外包和大厂的前端代码,差在凌晨三点
前端·面试·github
m0_敲代码的彭于晏16810 小时前
Vercel 前端部署与全栈开发实战指南
前端·状态模式
徐小夕10 小时前
花了3个月,我们做了一款 AI 版的“飞书文档”
前端·算法·github
贾伟康10 小时前
【化学实验室|17】HarmonyOS ArkTS 亮暗色与视觉令牌实战:集中颜色、间距和交互状态避免页面割裂
前端·移动开发·harmonyos·arkts·arkui
Csvn11 小时前
SSR/SSG/ISR(上):峡谷与渲染策略的时间预算
前端