普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月27日首页

HTML&CSS:纯CSS实现随机转盘抽奖机——无JS,全靠现代CSS黑科技!

作者 前端Hardy
2026年2月27日 10:30

这个 HTML 页面实现了一个交互式转盘抽奖效果,使用了现代 CSS 的一些实验性特性 (如 random() 函数、@layer、sibling-index() 等),并结合 SVG 图标和渐变背景,营造出一个视觉吸引、功能完整的“幸运大转盘”界面。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS随机函数实现转盘效果</title>
    <style>
        @import url(https://fonts.bunny.net/css?family=jura:300,700);
        @layer base, notes, demo;

        @layer demo {
            :root {
                --items: 12;
                --spin-easing: cubic-bezier(0, 0.061, 0, 1.032);
                --slice-angle: calc(360deg / var(--items));
                --start-angle: calc(var(--slice-angle) / 2);

                --wheel-radius: min(40vw, 300px);
                --wheel-size: calc(var(--wheel-radius) * 2);
                --wheel-padding: 10%;
                --item-radius: calc(var(--wheel-radius) - var(--wheel-padding));

                --wheel-bg-1: oklch(0.80 0.16 30);
                --wheel-bg-2: oklch(0.74 0.16 140);
                --wheel-bg-3: oklch(0.80 0.16 240);
                --wheel-bg-4: oklch(0.74 0.16 320);

                --marker-bg-color: black;
                --button-text-color: white;
                --spin-duration: random(1s, 3s);

                --random-angle: random(1200deg, 4800deg, by var(--slice-angle));

                @supports not (rotate: random(1deg, 10deg)) {
                    --spin-duration: 2s;
                    --random-angle: 4800deg;
                }
            }


            .wrapper {
                position: relative;
                inset: 0;
                margin: auto;
                width: var(--wheel-size);
                aspect-ratio: 1;

                input[type=checkbox] {
                    position: absolute;
                    opacity: 0;
                    width: 1px;
                    height: 1px;
                    pointer-events: none;
                }

                &:has(input[type=checkbox]:checked) {
                    --spin-it: 1;
                    --btn-spin-scale: 0;
                    --btn-spin-event: none;
                    --btn-spin-trans-duration: var(--spin-duration);
                    --btn-reset-scale: 1;
                    --btn-reset-event: auto;
                    --btn-reset-trans-delay: var(--spin-duration);
                }

                .controls {
                    position: absolute;
                    z-index: 2;
                    inset: 0;
                    margin: auto;
                    width: min(100px, 10vw);
                    aspect-ratio: 1;
                    background: var(--marker-bg-color);
                    border-radius: 9in;
                    transition: scale 150ms ease-in-out;

                    &:has(:hover, :focus-visible) label {
                        scale: 1.2;
                        rotate: 20deg;
                    }

                    &::before {
                        content: '';
                        position: absolute;
                        top: 0;
                        left: 50%;
                        translate: -50% -50%;
                        width: 20%;
                        aspect-radio: 2/10;
                        background-color: transparent;
                        border: 2vw solid var(--marker-bg-color);
                        border-bottom-width: 4vw;
                        border-top: 0;
                        border-left-color: transparent;
                        border-right-color: transparent;
                        z-index: -1;
                    }

                    label {
                        cursor: pointer;
                        display: grid;
                        place-items: center;
                        width: 100%;
                        aspect-ratio: 1;
                        color: var(--button-text-color);
                        transition:
                            rotate 150ms ease-in-out,
                            scale 150ms ease-in-out;

                        svg {
                            grid-area: 1/1;
                            width: 50%;
                            height: 50%;
                            transition-property: scale;
                            transition-timing-function: ease-in-out;

                            &:first-child {
                                transition-duration: var(--btn-spin-trans-duration, 150ms);
                                scale: var(--btn-spin-scale, 1);
                                pointer-events: var(--btn-spin-event, auto);
                            }

                            &:last-child {
                                transition-duration: 150ms;
                                transition-delay: var(--btn-reset-trans-delay, 0ms);
                                scale: var(--btn-reset-scale, 0);
                                pointer-events: var(--btn-reset-event, none);
                            }
                        }
                    }


                }

                &:has(input[type=checkbox]:checked)>.wheel {
                    animation: --spin-wheel var(--spin-duration, 3s) var(--spin-easing, ease-in-out) forwards;
                }

                .wheel {
                    position: absolute;
                    inset: 0;
                    border-radius: 99vw;
                    border: 1px solid white;
                    user-select: none;
                    font-size: 24px;
                    font-weight: 600;
                    background: repeating-conic-gradient(from var(--start-angle),
                            var(--wheel-bg-1) 0deg var(--slice-angle),
                            var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle) * 2),
                            var(--wheel-bg-3) calc(var(--slice-angle) * 2) calc(var(--slice-angle) * 3),
                            var(--wheel-bg-4) calc(var(--slice-angle) * 3) calc(var(--slice-angle) * 4));

                    >span {
                        --i: sibling-index();

                        @supports not (sibling-index(0)) {
                            &:nth-child(1) {
                                --i: 1;
                            }

                            &:nth-child(2) {
                                --i: 2;
                            }

                            &:nth-child(3) {
                                --i: 3;
                            }

                            &:nth-child(4) {
                                --i: 4;
                            }

                            &:nth-child(5) {
                                --i: 5;
                            }

                            &:nth-child(6) {
                                --i: 6;
                            }

                            &:nth-child(7) {
                                --i: 7;
                            }

                            &:nth-child(8) {
                                --i: 8;
                            }

                            &:nth-child(9) {
                                --i: 9;
                            }

                            &:nth-child(10) {
                                --i: 10;
                            }

                            &:nth-child(11) {
                                --i: 11;
                            }

                            &:nth-child(12) {
                                --i: 12;
                            }
                        }
                        position: absolute;
                        offset-path: circle(var(--item-radius) at 50% 50%);
                        offset-distance: calc(var(--i) / var(--items) * 100%);
                        offset-rotate: auto;
                    }
                }
            }

            @keyframes --spin-wheel {
                to {
                    rotate: var(--random-angle);
                }
            }
        }

        @layer notes {
            section.notes {
                margin: auto;
                width: min(80vw, 56ch);

                p {
                    text-wrap: pretty;
                }

                > :first-child {
                    color: red;
                    background: rgb(255, 100, 103);
                    padding: .5em;
                    color: white;

                    @supports (rotate: random(1deg, 10deg)) {
                        display: none;
                    }
                }
            }
        }

        @layer base {

            *,
            ::before,
            ::after {
                box-sizing: border-box;
            }

            :root {
                color-scheme: light dark;
                --bg-dark: rgb(21 21 21);
                --bg-light: rgb(248, 244, 238);
                --txt-light: rgb(10, 10, 10);
                --txt-dark: rgb(245, 245, 245);
                --line-light: rgba(0 0 0 / .75);
                --line-dark: rgba(255 255 255 / .25);
                --clr-bg: light-dark(var(--bg-light), var(--bg-dark));
                --clr-txt: light-dark(var(--txt-light), var(--txt-dark));
                --clr-lines: light-dark(var(--line-light), var(--line-dark));
            }

            body {
                background-color: var(--clr-bg);
                color: var(--clr-txt);
                min-height: 100svh;
                margin: 0;
                padding: 2rem;
                font-family: "Jura", sans-serif;
                font-size: 1rem;
                line-height: 1.5;
                display: grid;
                place-content: center;
                gap: 2rem;
            }

            strong {
                font-weight: 700;
            }
        }
    </style>
</head>

<body>

    <section class="wrapper">
        <input type="checkbox" id="radio-spin">
        <div class="controls">
            <label for="radio-spin">
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Spin the Wheel" title="Spin the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M14 12a2 2 0 1 0 -4 0a2 2 0 0 0 4 0" />
                    <path d="M12 21c-3.314 0 -6 -2.462 -6 -5.5s2.686 -5.5 6 -5.5" />
                    <path d="M21 12c0 3.314 -2.462 6 -5.5 6s-5.5 -2.686 -5.5 -6" />
                    <path d="M12 14c3.314 0 6 -2.462 6 -5.5s-2.686 -5.5 -6 -5.5" />
                    <path d="M14 12c0 -3.314 -2.462 -6 -5.5 -6s-5.5 2.686 -5.5 6" />
                </svg>

                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                    stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                    aria-label="Reset the Wheel" title="Reset the Wheel">
                    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                    <path d="M3.06 13a9 9 0 1 0 .49 -4.087" />
                    <path d="M3 4.001v5h5" />
                    <path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
                </svg>
            </label>
        </div>

        <div id="wheel" class="wheel">
            <span>乔丹</span>
            <span>詹姆斯</span>
            <span>布莱恩特</span>
            <span>约翰逊</span>
            <span>库里</span>
            <span>奥尼尔</span>
            <span>邓肯</span>
            <span>贾巴尔</span>
            <span>杜兰特</span>
            <span>哈登</span>
            <span>字母哥</span>
            <span>伦纳德</span>
        </div>
    </section>
</body>

</html>

HTML

  • section:转盘核心容器(语义化区块)相对定位,包含转盘所有子元素
  • input:转盘触发开关(核心交互控件) 视觉隐藏(opacity:0),通过「选中 / 未选中」触发动画
  • div controls:转盘中心按钮容器。绝对定位,层级高于转盘,包含点击触发的 label
  • label :绑定隐藏复选框,作为可点击按钮。点击该标签等价于点击复选框,触发状态切换
  • svg:显示「旋转」「重置」图标。两个 SVG 重叠,通过 CSS 控制显隐
  • div wheel:转盘本体。圆形布局,包含 12 个奖项文本
  • span:转盘奖项文本(12 个 NBA 球星名称) 每个 span 对应转盘一个分区,通过 CSS 定位到圆形轨道

CSS

1. 样式分层管理(@layer)

@layer base, notes, demo;
  • base:全局基础样式(盒模型、明暗色模式、页面布局),优先级最低;
  • notes:兼容提示文本样式,优先级中等;
  • demo:转盘核心样式(尺寸、动画、交互),优先级最高;

作用:按层级管理样式,避免样式冲突,便于维护。

2. 核心变量定义(:root)

:root {
  --items: 12; /* 转盘分区数量 */
  --slice-angle: calc(360deg / var(--items)); /* 每个分区角度(30°) */
  --wheel-radius: min(40vw, 300px); /* 转盘半径(自适应,最大300px) */
  --spin-duration: random(1s, 3s); /* 随机旋转时长(1-3秒) */
  --random-angle: random(1200deg, 4800deg, by var(--slice-angle)); /* 随机旋转角度(步长30°) */
  /* 浏览器兼容降级:不支持random()则固定值 */
  @supports not (rotate: random(1deg, 10deg)) {
    --spin-duration: 2s;
    --random-angle: 4800deg;
  }
}

核心:用变量统一管理转盘尺寸、角度、动画参数,random() 实现「随机旋转」核心效果,同时做浏览器兼容降级。

3. 转盘交互触发逻辑

/* 监听复选框选中状态,更新变量控制图标/动画 */
.wrapper:has(input[type=checkbox]:checked) {
  --btn-spin-scale: 0; /* 隐藏旋转图标 */
  --btn-reset-scale: 1; /* 显示重置图标 */
}
/* 选中时触发转盘旋转动画 */
.wrapper:has(input[type=checkbox]:checked)>.wheel {
  animation: --spin-wheel var(--spin-duration) var(--spin-easing) forwards;
}
/* 旋转动画:转到随机角度后保持状态 */
@keyframes --spin-wheel {
  to { rotate: var(--random-angle); }
}

核心:通过 :has() 伪类监听复选框状态,触发转盘动画,forwards 确保动画结束后不回弹。

4. 转盘视觉与布局

.wheel {
  border-radius: 99vw; /* 圆形转盘 */
  /* 四色循环锥形渐变,实现转盘分区背景 */
  background: repeating-conic-gradient(from var(--start-angle),
    var(--wheel-bg-1) 0deg var(--slice-angle),
    var(--wheel-bg-2) var(--slice-angle) calc(var(--slice-angle)*2),
    var(--wheel-bg-3) calc(var(--slice-angle)*2) calc(var(--slice-angle)*3),
    var(--wheel-bg-4) calc(var(--slice-angle)*3) calc(var(--slice-angle)*4));
  >span {
    offset-path: circle(var(--item-radius) at 50% 50%); /* 圆形轨道 */
    offset-distance: calc(var(--i) / var(--items) * 100%); /* 按索引定位到对应分区 */
  }
}

核心:repeating-conic-gradient 实现转盘彩色分区,offset-path 让奖项文本沿圆形轨道均匀分布。

5. 中心按钮交互

.controls {
  position: absolute;
  z-index: 2; /* 层级高于转盘,确保可点击 */
  border-radius: 9in; /* 圆形按钮 */
  &::before { /* 转盘顶部指针 */
    content: '';
    border: 2vw solid var(--marker-bg-color);
    border-bottom-width: 4vw;
    border-top/left/right-color: transparent; /* 三角指针形状 */
  }
  label:hover { scale: 1.2; rotate: 20deg; } /* 鼠标悬浮时图标放大旋转 */
}

核心:伪元素实现转盘「指针」,hover 动效提升交互反馈,两个 SVG 图标通过 scale 控制显隐。

6. 全局基础样式

@layer base {
  :root {
    color-scheme: light dark; /* 适配系统明暗色模式 */
    --clr-bg: light-dark(var(--bg-light), var(--bg-dark)); /* 自动切换背景色 */
  }
  body {
    min-height: 100svh; /* 适配移动端安全区 */
    display: grid;
    place-content: center; /* 垂直水平居中 */
  }
}

核心:light-dark() 自动适配系统明暗模式,100svh 避免移动端地址栏遮挡,网格布局实现内容居中。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

分享URL地址到微信朋友圈没有缩略图?

作者 DeathGhost
2026年2月27日 09:39

分享URL网址到朋友圈啥时候支持og:image?还是说一直都支持的?一直也没在意,直到今天试了试我的鸿蒙系统应用分享,才……

以前用土方法在页面display none 一个图片,一直以为需要调用微信内部方法方可展示缩略图。😂

meta property="og:xxx" 以前倒是看到过,一直没在意~

最近手机升级到鸿蒙 (HarmonyOS) 系统,发现以前的方法竟然失效了 —— 更换了多个类型的浏览器分享朋友圈,页面缩略图都是空的。🙄

于是试着用了下 OG 标签,结果…居然可以了 ( 就试了下朋友圈,其他的暂时也懒得理 ) !🧐

分享URL地址到微信朋友圈没有缩略图?

示例代码

<head>
    <meta property="og:title" content="您分享的页面标题" />
    <meta property="og:description" content="您分享的页面描述" />
    <!-- 这是最关键的一行 -->
    <meta property="og:image" content="https://您的域名.com/图片路径/thumbnail.jpg" />
    <meta property="og:url" content="https://您的域名.com/当前页面路径" />
    <meta property="og:type" content="website" />
</head>

og:image content 必须是完整的、以 http://https:// 开头的绝对URL。相对路径(如 /images/thumb.jpg或//开始的地址)是无效的。

图片格式:支持 JPG, PNG, WebP 等常见格式。

效果演示图

分享到朋友圈 - 缩略图

我的 Nunjucks 模板代码

{# 作者 -#}
    {%- if basic and basic.site_info and basic.site_info.og_author -%}
    <meta property="article:author" content="{{ basic.site_info.og_author }}" />
    {% endif %}
    {# 发布时间 -#}
    {%- if basic and basic.site_info and basic.site_info.og_published_time -%}
    <meta property="article:published_time" content="{{ basic.site_info.og_published_time }}" />
    {%- endif -%}
    {# 标签 #}
    {%- if basic and basic.site_info and basic.site_info.keywords -%}
    {%- set keywords = basic.site_info.keywords -%}
    {%- if keywords is string %}
    {%- set tagArray = keywords.split(',') -%}
    {%- elif keywords is array -%}
    {%- set tagArray = keywords -%}
    {% else %}
    {%- set tagArray = [] -%}
    {% endif %}
    {# 生成标签 #}
    {%- for tag in tagArray -%}
    {%- set cleanTag = tag | trim -%}
    {%- if cleanTag -%}
    <meta property="article:tag" content="{{ cleanTag }}" />
    {%- endif -%}
    {%- endfor -%}
    {%- endif -%}

填坑经历

一开始我设置成了下面这样 ( 没有带 https ) ,分享出来是个空图 ⬜️

<meta property="og:image" content="//static.deathghost.cn/assets/avatar.jpg" />

所以,配置时按照常用的规范来,并注意上面提到的要点即可。这玩意以前都有,就是没用,也不知道应用支持不支持,今天才试了试~

来源:www.deathghost.cn/article/htm…

昨天以前首页

HTML&CSS:高颜值产品卡片页面,支持主题切换

作者 前端Hardy
2026年2月26日 16:01

这是一个产品卡片页面,无 JS 实现图片切换主题切换、全设备响应式适配,兼顾美观与实用性,值得大家学习。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>现代极简卧室套装</title>
    <style>
        body {
            font-family: "Roboto Serif", serif;
            display: flex;
            justify-content: center;
            align-items: center;
            margin: 0;
            min-height: 100vh;
            background-color: #f6f1ea;
            background-image: radial-gradient(circle at 15% 20%, rgba(232, 186, 142, 0.35) 0%, rgba(232, 186, 142, 0.18) 20%, rgba(232, 186, 142, 0.08) 35%, transparent 60%), radial-gradient(circle at 85% 75%, rgba(220, 160, 110, 0.35) 0%, rgba(220, 160, 110, 0.15) 25%, transparent 55%), radial-gradient(circle at 60% 10%, rgba(255, 210, 170, 0.25) 0%, transparent 50%);
            background-repeat: no-repeat;
            background-attachment: fixed;
        }

        body::after {
            content: "";
            position: fixed;
            inset: 0;
            pointer-events: none;
            z-index: 10;
            mix-blend-mode: saturation;
            background: radial-gradient(circle at 20% 25%, rgba(255, 200, 150, 0.35), rgba(255, 200, 150, 0.15) 30%, transparent 60%), radial-gradient(circle at 80% 70%, rgba(255, 170, 110, 0.3), transparent 60%);
            filter: blur(80px);
        }

        .product-card {
            border-radius: 12rem;
            corner-shape: squircle;
            padding: 1rem 1.5rem 1rem 1rem;
            max-width: 800px;
            background: linear-gradient(145deg, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0.55));
            backdrop-filter: blur(20px);
            border: 1px solid rgba(255, 255, 255, 0.4);
            box-shadow: 0 40px 80px rgba(206, 168, 132, 0.1), 0 20px 40px rgba(0, 0, 0, 0.1), 0 0 120px rgba(198, 169, 126, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6), -5px -5px 10px 0 #fffce9;
        }

        @media (max-width: 768px) {
            .product-card {
                border-radius: 5rem;
            }
        }

        .product-card .card-content {
            display: flex;
            justify-content: center;
            gap: 32px;
        }

        @media (max-width: 768px) {
            .product-card .card-content {
                flex-direction: column;
            }
        }

        .product-card .image-container {
            position: relative;
            border-radius: 12rem;
            width: 45vw;
            max-width: 450px;
            corner-shape: squircle;
            aspect-ratio: 1/1;
            overflow: hidden;
        }

        @media (max-width: 768px) {
            .product-card .image-container {
                width: 100%;
                border-radius: 5rem;
            }
        }

        .product-card .info-container {
            display: flex;
            flex-direction: column;
            justify-content: center;
            color: #5f5a55;
        }

        .product-card .info-container .brand {
            display: block;
            padding-bottom: 3rem;
            font-size: 0.85rem;
        }

        .product-card .info-container h1 {
            line-height: 100%;
            font-size: 2rem;
            font-weight: 600;
            letter-spacing: -0.1rem;
            margin: 0;
            padding: 0;
            transform: scaleY(2);
        }

        .product-card .info-container .price {
            font-size: 1.3rem;
            font-weight: 400;
            margin: 0;
            padding: 2.5rem 0 0;
            color: #c89b5e;
        }

        .product-card .info-container .description {
            max-width: 280px;
            font-size: 0.85rem;
            font-weight: 200;
            line-height: 150%;
            margin: 0;
            padding: 1rem 0;
        }

        .product-card .info-container .btn-primary {
            margin-top: 1rem;
            padding: 1rem 2rem;
            max-width: 200px;
            font-size: 1rem;
            letter-spacing: 1px;
            color: #ffffff;
            background: linear-gradient(145deg, #d8a45c, #b97a2f);
            border: none;
            border-radius: 40px;
            cursor: pointer;
            box-shadow: 0 8px 20px rgba(201, 155, 94, 0.35), 0 0 25px rgba(201, 155, 94, 0.15), inset 0 2px 6px rgba(255, 255, 255, 0.3);
            transition: 0.3s ease;
        }

        @media (max-width: 768px) {
            .product-card .info-container .btn-primary {
                max-width: none;
            }
        }

        .product-card .info-container .btn-primary:hover {
            transform: translateY(-2px);
            filter: brightness(1.05);
            box-shadow: 0 10px 25px rgba(185, 122, 47, 0.45), inset 0 2px 6px rgba(255, 255, 255, 0.3);
        }

        .product-card .info-container .btn-primary:active {
            transform: translateY(1px);
            box-shadow: 0 5px 15px rgba(185, 122, 47, 0.3), inset 0 3px 6px rgba(0, 0, 0, 0.15);
        }

        .image {
            aspect-ratio: 1/1;
            width: 100%;
            background: url("https://assets.codepen.io/662051/Gemini_Generated_Image_j4wi73j4wi73j4wi.png") center;
            background-size: cover;
            transition: 0.4s ease;
        }

        .theme-switch__input:checked~.image {
            background-image: url("https://assets.codepen.io/662051/Gemini_Generated_Image_2q32sc2q32sc2q32.png");
        }

        .theme-switch {
            position: absolute;
            left: 50%;
            bottom: 20px;
            transform: translateX(-50%);
            cursor: pointer;
        }

        .theme-switch__input {
            display: none;
        }

        .theme-switch__container {
            position: relative;
            width: 60px;
            height: 32px;
            padding: 5px;
            background-color: #e2e2e2;
            border-radius: 32px;
            display: flex;
            align-items: center;
            box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.1);
            transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
        }

        .theme-switch__circle {
            width: 30px;
            height: 30px;
            background-color: #333;
            border-radius: 50%;
            z-index: 2;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
            transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
        }

        .theme-switch__icons {
            position: absolute;
            width: 100%;
            box-sizing: border-box;
            z-index: 2;
        }

        .theme-switch .icon {
            width: 18px;
            height: 18px;
            transition: opacity 0.3s ease;
        }

        .theme-switch .icon--sun {
            opacity: 1;
            color: #fff;
            transform: translate(6px, 2px);
        }

        .theme-switch .icon--moon {
            opacity: 0;
            color: #333;
            transform: translate(15px, 2px);
        }

        .theme-switch__input:checked+.image+.theme-switch .theme-switch__container {
            background-color: #222;
        }

        .theme-switch__input:checked+.image+.theme-switch .theme-switch__circle {
            transform: translateX(30px);
            background-color: #fff;
        }

        .theme-switch__input:checked+.image+.theme-switch .icon--sun {
            opacity: 0;
        }

        .theme-switch__input:checked+.image+.theme-switch .icon--moon {
            opacity: 1;
            color: #333;
        }

        #dev {
            font-family: "Montserrat", sans-serif;
            position: fixed;
            top: 10px;
            left: 10px;
            padding: 1em;
            font-size: 14px;
            color: #333;
            background-color: white;
            border-radius: 25px;
            cursor: pointer;
        }

        #dev a {
            text-decoration: none;
            font-weight: bold;
            color: #333;
            transition: all 0.4s ease;
        }

        #dev a:hover {
            color: #ef5350;
            text-decoration: underline;
        }

        #dev span {
            display: inline-block;
            color: pink;
            transition: all 0.4s ease;
        }

        #dev span:hover {
            transform: scale(1.2);
        }
    </style>
</head>

<body>
    <div class="product-card">
        <div class="card-content">
            <div class="image-container">
                <input type="checkbox" id="theme-toggle" class="theme-switch__input">
                <div class="image"></div>
                <label for="theme-toggle" class="theme-switch">
                    <div class="theme-switch__container">
                        <div class="theme-switch__circle"></div>
                        <div class="theme-switch__icons">
                            <svg class="icon icon--sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
                                fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
                                stroke-linejoin="round">
                                <circle cx="12" cy="12" r="5"></circle>
                                <line x1="12" y1="1" x2="12" y2="3"></line>
                                <line x1="12" y1="21" x2="12" y2="23"></line>
                                <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
                                <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
                                <line x1="1" y1="12" x2="3" y2="12"></line>
                                <line x1="21" y1="12" x2="23" y2="12"></line>
                                <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
                                <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
                            </svg>
                            <svg class="icon icon--moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
                                fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
                                stroke-linejoin="round">
                                <path d="M21 12.79A9 9 0 1 1 11.21 3
                       7 7 0 0 0 21 12.79z"></path>
                            </svg>
                        </div>
                    </div>
                </label>
            </div>
            <div class="info-container">
                <header>
                    <span class="brand">HOLIME</span>
                    <h1 class="title">现代极简<br />卧室套装</h1>
                    <p class="price">$50.00 <span></span></p>
                    <p class="description">
                        打造宁静休憩空间,这套现代极简卧室套装融合永恒设计与舒适体验,为您的家注入优雅与安宁。
                    </p>
                </header>
                <button class="btn-primary">加入购物车</button>
            </div>
        </div>
    </div>
</body>

</html>

HTML

  • prouct-card:产品卡片容器。核心视觉容器,用毛玻璃效果、圆角、渐变背景打造高级感
  • card-content:卡片内容区。弹性布局,分「图片区 + 信息区」,移动端自动改为垂直布局
  • image-container:产品图片容器。固定宽高比(1:1);② 包含切换图片的复选框、图片、切换按钮;圆角适配移动端
  • info-container:产品信息区。垂直布局,包含品牌、标题、价格、描述、加入购物车按钮
  • theme-toggle:图片切换复选框。隐藏的核心交互控件,通过「选中 / 未选中」切换产品图片
  • theme-switch:切换按钮(夜间/白天)。视觉化的切换控件,绑定到隐藏的复选框,实现交互反馈

CSS

全局样式 & 背景

body {
  font-family: "Roboto Serif", serif;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  /* 背景层:暖色调径向渐变,营造温馨的卧室氛围 */
  background-color: #f6f1ea;
  background-image: radial-gradient(...), radial-gradient(...), radial-gradient(...);
  background-repeat: no-repeat;
  background-attachment: fixed;
}
body::after {
  /* 叠加模糊渐变层,增强层次感,mix-blend-mode 提升饱和度 */
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none; /* 不遮挡交互 */
  mix-blend-mode: saturation;
  background: radial-gradient(...);
  filter: blur(80px);
}

核心:flex 实现页面居中 + 多层径向渐变背景 + 伪元素叠加模糊层,打造「柔和、有呼吸感」的视觉基底。

产品卡片核心样式

.product-card {
  border-radius: 12rem; /* 超大圆角,接近胶囊/圆角矩形 */
  corner-shape: squircle; /* 松鼠角(非标准但现代浏览器支持,更圆润的圆角) */
  padding: 1rem 1.5rem 1rem 1rem;
  max-width: 800px;
  /* 毛玻璃核心:半透明背景 + backdrop-filter */
  background: linear-gradient(145deg, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0.55));
  backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.4);
}

核心:backdrop-filter: blur(20px) 实现毛玻璃效果,linear-gradient 半透明白色渐变增强通透感,超大圆角提升现代感。

响应式布局

@media (max-width: 768px) {
  .product-card { border-radius: 5rem; }
  .product-card .card-content { flex-direction: column; }
  .product-card .image-container { width: 100%; border-radius: 5rem; }
  .product-card .info-container .btn-primary { max-width: none; }
}

核心:屏幕宽度 ≤768px(移动端)时,① 缩小卡片 / 图片圆角;② 图片 + 信息从「横向排列」改为「垂直排列」;③ 按钮宽度自适应,适配移动端交互。

图片切换交互

/* 默认图片 */
.image {
  aspect-ratio: 1/1;
  width: 100%;
  background: url("xxx.png") center;
  background-size: cover;
  transition: 0.4s ease;
}
/* 复选框选中时切换图片 */
.theme-switch__input:checked~.image {
  background-image: url("yyy.png");
}

核心:利用 CSS 相邻兄弟选择器 ~,监听复选框 :checked 状态,切换背景图片,配合 transition 实现平滑过渡。

开关按钮 & 按钮动效

/* 开关按钮样式切换 */
.theme-switch__input:checked+.image+.theme-switch .theme-switch__container {
  background-color: #222;
}
.theme-switch__input:checked+.image+.theme-switch .theme-switch__circle {
  transform: translateX(30px);
  background-color: #fff;
}
/* 加入购物车按钮 hover/active 动效 */
.btn-primary:hover {
  transform: translateY(-2px);
  filter: brightness(1.05);
  box-shadow: ...;
}
.btn-primary:active {
  transform: translateY(1px);
  box-shadow: ...;
}

核心:① 开关按钮的「白天 / 夜间」图标显隐、背景色、滑块位置随复选框状态变化;② 按钮 hover 时上移 + 亮度提升,active 时下移 + 阴影缩小,模拟「按压反馈」。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

构建无障碍组件之Checkbox pattern

作者 anOnion
2026年2月23日 21:23

Checkbox Pattern 详解:构建无障碍复选框组件

复选框(Checkbox)是表单中最常见的交互元素之一,支持双状态(选中/未选中)和三状态(选中/未选中/部分选中)两种类型。本文基于 W3C WAI-ARIA Checkbox Pattern 规范,详解如何构建无障碍的复选框组件。

一、Checkbox 的定义与核心概念

复选框是一种允许用户进行二元或三元选择的控件。根据使用场景,复选框分为两种类型:

1.1 双状态复选框(Dual-State Checkbox)

在两个状态之间切换:

  • 选中(Checked):复选框被选中
  • 未选中(Not Checked):复选框未被选中

1.2 三状态复选框(Tri-State Checkbox)

在三个状态之间切换:

  • 选中(Checked):复选框被选中
  • 未选中(Not Checked):复选框未被选中
  • 部分选中(Partially Checked):表示一组选项中部分被选中

1.3 三状态复选框的典型应用场景

三状态复选框常用于软件安装程序或权限设置中,一个总控复选框控制整组选项的状态:

  • 全部选中:如果组内所有选项都被选中,总控复选框显示为选中状态
  • 部分选中:如果组内部分选项被选中,总控复选框显示为部分选中状态
  • 全部未选中:如果组内没有选项被选中,总控复选框显示为未选中状态

用户可以通过点击总控复选框一次性改变整组选项的状态:

  • 点击选中的总控复选框 → 取消全选
  • 点击未选中的总控复选框 → 全选
  • 点击部分选中的总控复选框 → 根据实现可能全选或恢复之前的状态

二、WAI-ARIA 角色与属性

2.1 基本角色

复选框具有 role="checkbox"

2.2 可访问标签

复选框的可访问标签可以通过以下方式提供:

  • 可见文本内容:直接包含在具有 role="checkbox" 的元素内的文本
  • aria-labelledby:引用包含标签文本的元素的 ID
  • aria-label:直接在复选框元素上设置标签文本
<!-- 方式一:可见文本内容 -->
<div role="checkbox" aria-checked="false">
  订阅新闻邮件
</div>

<!-- 方式二:aria-labelledby -->
<span id="newsletter-label">订阅新闻邮件</span>
<div role="checkbox" aria-checked="false" aria-labelledby="newsletter-label"></div>

<!-- 方式三:aria-label -->
<div role="checkbox" aria-checked="false" aria-label="订阅新闻邮件"></div>

2.3 状态属性

2.4 分组属性

如果一组复选框作为逻辑组呈现且有可见标签:

<fieldset role="group" aria-labelledby="group-label">
  <legend id="group-label">选择权限</legend>
  <label><input type="checkbox" /> 读取</label>
  <label><input type="checkbox" /> 写入</label>
  <label><input type="checkbox" /> 删除</label>
</fieldset>

2.5 描述属性

如果包含额外的描述性静态文本,使用 aria-describedby

<div role="checkbox" aria-checked="false" aria-describedby="terms-desc">
  我同意服务条款
</div>
<p id="terms-desc">点击此处查看完整的服务条款内容</p>

三、键盘交互规范

当复选框获得焦点时:

按键 功能
Space 改变复选框的状态(选中/未选中/部分选中)

四、实现方式

4.1 双状态复选框

原生 HTML 实现(推荐)
<label>
  <input type="checkbox" name="newsletter" />
  订阅新闻邮件
</label>
ARIA 实现(自定义样式)
<div 
  role="checkbox" 
  tabindex="0" 
  aria-checked="false"
  onclick="toggleCheckbox(this)"
  onkeydown="handleKeydown(event, this)">
  <span class="checkbox-icon" aria-hidden="true"></span>
  订阅新闻邮件
</div>

<script>
  function toggleCheckbox(checkbox) {
    const isChecked = checkbox.getAttribute('aria-checked') === 'true';
    checkbox.setAttribute('aria-checked', !isChecked);
  }
  
  function handleKeydown(event, checkbox) {
    if (event.key === ' ') {
      event.preventDefault();
      toggleCheckbox(checkbox);
    }
  }
</script>

4.2 三状态复选框(全选/取消全选)

<fieldset role="group" aria-labelledby="permissions-label">
  <legend id="permissions-label">文件权限</legend>
  
  <!-- 总控复选框 -->
  <label>
    <input 
      type="checkbox" 
      id="select-all"
      aria-checked="false"
      onchange="toggleAll(this)" />
    全选
  </label>
  
  <!-- 子复选框组 -->
  <div class="checkbox-group">
    <label>
      <input 
        type="checkbox" 
        name="permission"
        value="read"
        onchange="updateSelectAll()" />
      读取
    </label>
    <label>
      <input 
        type="checkbox" 
        name="permission"
        value="write"
        onchange="updateSelectAll()" />
      写入
    </label>
    <label>
      <input 
        type="checkbox" 
        name="permission"
        value="delete"
        onchange="updateSelectAll()" />
      删除
    </label>
  </div>
</fieldset>

<script>
  function toggleAll(selectAllCheckbox) {
    const checkboxes = document.querySelectorAll('input[name="permission"]');
    const isChecked = selectAllCheckbox.checked;
    
    checkboxes.forEach(checkbox => {
      checkbox.checked = isChecked;
    });
    
    updateSelectAllState();
  }
  
  function updateSelectAll() {
    updateSelectAllState();
  }
  
  function updateSelectAllState() {
    const selectAllCheckbox = document.getElementById('select-all');
    const checkboxes = document.querySelectorAll('input[name="permission"]');
    const checkedCount = document.querySelectorAll('input[name="permission"]:checked').length;
    
    if (checkedCount === 0) {
      selectAllCheckbox.checked = false;
      selectAllCheckbox.indeterminate = false;
      selectAllCheckbox.setAttribute('aria-checked', 'false');
    } else if (checkedCount === checkboxes.length) {
      selectAllCheckbox.checked = true;
      selectAllCheckbox.indeterminate = false;
      selectAllCheckbox.setAttribute('aria-checked', 'true');
    } else {
      selectAllCheckbox.checked = false;
      selectAllCheckbox.indeterminate = true;
      selectAllCheckbox.setAttribute('aria-checked', 'mixed');
    }
  }
</script>

4.3 使用原生 HTML 实现三状态效果

HTML5 的 indeterminate 属性可以实现部分选中视觉效果:

<label>
  <input 
    type="checkbox" 
    id="master-checkbox"
    onclick="handleMasterClick(this)" />
  全选
</label>

<label><input type="checkbox" class="child-checkbox" onchange="updateMaster()" /> 选项 1</label>
<label><input type="checkbox" class="child-checkbox" onchange="updateMaster()" /> 选项 2</label>
<label><input type="checkbox" class="child-checkbox" onchange="updateMaster()" /> 选项 3</label>

<script>
  function updateMaster() {
    const master = document.getElementById('master-checkbox');
    const children = document.querySelectorAll('.child-checkbox');
    const checkedCount = document.querySelectorAll('.child-checkbox:checked').length;
    
    if (checkedCount === 0) {
      master.checked = false;
      master.indeterminate = false;
    } else if (checkedCount === children.length) {
      master.checked = true;
      master.indeterminate = false;
    } else {
      master.checked = false;
      master.indeterminate = true;
    }
  }
  
  function handleMasterClick(master) {
    const children = document.querySelectorAll('.child-checkbox');
    const isChecked = master.checked;
    
    children.forEach(child => {
      child.checked = isChecked;
    });
  }
</script>

五、常见应用场景

5.1 表单选项

用户注册表单中的选项选择:

<fieldset>
  <legend>兴趣爱好</legend>
  <label><input type="checkbox" name="hobby" value="reading" /> 阅读</label>
  <label><input type="checkbox" name="hobby" value="sports" /> 运动</label>
  <label><input type="checkbox" name="hobby" value="music" /> 音乐</label>
  <label><input type="checkbox" name="hobby" value="travel" /> 旅行</label>
</fieldset>

5.2 权限设置

系统权限管理中的功能授权:

<fieldset role="group" aria-labelledby="permissions-heading">
  <h3 id="permissions-heading">用户权限</h3>
  
  <label>
    <input type="checkbox" id="select-all-permissions" />
    全选所有权限
  </label>
  
  <div class="permission-group">
    <label><input type="checkbox" name="permission" value="view" /> 查看数据</label>
    <label><input type="checkbox" name="permission" value="create" /> 创建记录</label>
    <label><input type="checkbox" name="permission" value="edit" /> 编辑内容</label>
    <label><input type="checkbox" name="permission" value="delete" /> 删除数据</label>
  </div>
</fieldset>

5.3 安装程序选项

软件安装时的组件选择:

<fieldset>
  <legend>选择安装组件</legend>
  
  <label>
    <input type="checkbox" id="select-all-components" />
    安装所有组件
  </label>
  
  <label><input type="checkbox" name="component" value="core" checked disabled /> 核心程序(必需)</label>
  <label><input type="checkbox" name="component" value="docs" /> 帮助文档</label>
  <label><input type="checkbox" name="component" value="plugins" /> 插件包</label>
  <label><input type="checkbox" name="component" value="shortcuts" /> 桌面快捷方式</label>
</fieldset>

5.4 表格行选择

数据表格中的批量操作:

<table role="grid">
  <thead>
    <tr>
      <th>
        <input type="checkbox" id="select-all-rows" aria-label="选择所有行" />
      </th>
      <th>姓名</th>
      <th>邮箱</th>
      <th>状态</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><input type="checkbox" class="row-checkbox" aria-label="选择张三" /></td>
      <td>张三</td>
      <td>zhangsan@example.com</td>
      <td>活跃</td>
    </tr>
    <tr>
      <td><input type="checkbox" class="row-checkbox" aria-label="选择李四" /></td>
      <td>李四</td>
      <td>lisi@example.com</td>
      <td>待审核</td>
    </tr>
  </tbody>
</table>

六、最佳实践

6.1 优先使用原生复选框

原生 HTML <input type="checkbox"> 提供完整的无障碍支持,包括:

  • 自动键盘交互(Space 键切换)
  • 屏幕阅读器自动播报状态
  • 浏览器原生样式和焦点管理

6.2 标签关联

始终使用 <label> 元素关联复选框和标签文本:

<!-- 推荐:使用 for 属性关联 -->
<input type="checkbox" id="agree" />
<label for="agree">我同意服务条款</label>

<!-- 推荐:使用嵌套方式 -->
<label>
  <input type="checkbox" />
  我同意服务条款
</label>

6.3 分组语义

相关复选框应使用 <fieldset><legend> 进行分组:

<fieldset>
  <legend>选择通知方式</legend>
  <label><input type="checkbox" /> 邮件通知</label>
  <label><input type="checkbox" /> 短信通知</label>
  <label><input type="checkbox" /> 应用内通知</label>
</fieldset>

6.4 状态同步

三状态复选框需要确保 DOM 属性与 ARIA 属性同步:

function updateTriState(checkbox, checkedCount, totalCount) {
  if (checkedCount === 0) {
    checkbox.checked = false;
    checkbox.indeterminate = false;
    checkbox.setAttribute('aria-checked', 'false');
  } else if (checkedCount === totalCount) {
    checkbox.checked = true;
    checkbox.indeterminate = false;
    checkbox.setAttribute('aria-checked', 'true');
  } else {
    checkbox.checked = false;
    checkbox.indeterminate = true;
    checkbox.setAttribute('aria-checked', 'mixed');
  }
}

6.5 视觉指示

确保复选框状态有清晰的视觉指示:

  • 未选中:空框
  • 选中:勾选标记
  • 部分选中:横线或减号

6.6 焦点管理

为自定义复选框提供清晰的焦点样式:

[role="checkbox"]:focus {
  outline: 2px solid #005a9c;
  outline-offset: 2px;
}

七、Checkbox 与 Radio 的区别

特性 Checkbox Radio
选择数量 可多选 单选
状态数 2 或 3 种 2 种(选中/未选中)
分组方式 逻辑分组 同一 name 属性互斥
典型用途 多选项、权限设置 单选项、性别选择
键盘交互 Space 切换 Arrow 移动选择

八、总结

构建无障碍的复选框组件需要关注三个核心:正确的语义化标记(优先使用原生 <input type="checkbox">)、清晰的状态管理(aria-checked 属性)、以及良好的标签关联(<label> 元素)。对于复杂的三状态场景,需要确保总控复选框与子复选框之间的状态同步,为屏幕阅读器用户提供准确的状态反馈。

遵循 W3C Checkbox Pattern 规范,我们能够创建既美观又包容的复选框组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

HTML 早已不是标签了,它现在是系统级接口:这 9 个 API 直接干翻常用 JS 库

2026年2月21日 07:36

HTML 早已不再是简单的“超文本标记”,它更像是一个连接底层硬件、浏览器内核与用户交互的系统级接口集合

在现代 Web 架构中,很多原本依赖庞大 JS 库(如 jQuery, Axios, Socket.io)实现的功能,现在通过原生 HTML API 就能以更低的功耗和更高的性能完成。

一、 Popover API:零 JS 实现“浮层顶层化”

场景: 在监控仪表盘中,点击“详细指标”展示一个不被父容器 overflow: hidden 遮挡的浮窗。

  • HTML 实现:

    HTML

    <button popovertarget="metric-detail">查看详情</button>
    
    <div id="metric-detail" popover>
      <h4>实时指标详情</h4>
      <p>CPU 负载: 85%</p>
      </div>
    
  • 底层干货: 它会自动进入浏览器的 Top Layer(顶层渲染层),层级永远高于 z-index: 9999,且无需任何 JS 监听点击外部关闭的逻辑。


二、 Dialog API:受控的模态对话框

场景: 监控报警触发时,弹出一个强制用户交互的模态确认框。

  • HTML 与 JS 交互:

    HTML

    <dialog id="alarm-dialog">
      <form method="dialog">
        <p>确认关闭此报警?</p>
        <button value="cancel">取消</button>
        <button value="confirm">确认</button>
      </form>
    </dialog>
    
    <script>
      const dialog = document.getElementById('alarm-dialog');
      // 1. 弹出模态框:自带背景遮罩 (::backdrop)
      dialog.showModal(); 
    
      // 2. 获取结果:无需监听按钮点击,直接监听 close 事件
      dialog.addEventListener('close', () => {
        console.log('用户选择了:', dialog.returnValue); // 'confirm' 或 'cancel'
      });
    </script>
    

三、 Speculation Rules API:让页面跳转“瞬发”

场景: 监控首页有很多链接通往“分析页”,你预测用户 80% 的概率会点第一个链接。

  • 具体配置:

    HTML

    <script type="speculationrules">
    {
      "prerender": [{
        "source": "list",
        "urls": ["/analysis/cpu-metrics"],
        "score": 0.8
      }]
    }
    </script>
    
  • 工程意义: 这不是简单的预加载,而是预渲染。浏览器会在后台开启一个隐形标签页渲染目标页面。当用户点击时,页面切换时间趋于 0ms


四、 View Transitions API:极致的 UI 平滑度

场景: 在监控系统中,从“列表视图”切换到“详情视图”,希望卡片能有一个平滑的缩放位移动画。

  • 代码实现:

    JavaScript

    function switchView() {
      // 1. 检查浏览器支持
      if (!document.startViewTransition) {
        updateDOM(); // 降级处理
        return;
      }
    
      // 2. 开启视图转换
      document.startViewTransition(() => {
        // 在回调函数中执行 DOM 变更
        updateDOM(); 
      });
    }
    
  • CSS 配合:

    CSS

    /* 给需要动画的元素定义一个唯一的转换名称 */
    .metric-card {
      view-transition-name: active-card;
    }
    
  • 原理: 浏览器会截取“旧状态”和“新状态”的快照,并自动在两者之间创建位移、缩放和淡入淡出动画。


五、 WebAssembly (Wasm) 与 JS 的零拷贝交互

场景: 监控系统中,前端需要实时计算成千上万个点的趋势。

  • 具体用法:

    JavaScript

    // 在 HTML 中直接通过 Module 引入
    import init, { calculate_metrics } from './analytics_bg.wasm';
    
    async function run() {
      await init();
      const buffer = new SharedArrayBuffer(1024); // 使用共享内存
      const view = new Float64Array(buffer);
      // 直接把内存地址传给 Wasm 处理,避免数据在大规模拷贝时的开销
      const result = calculate_metrics(view);
    }
    
  • 工程价值: HTML 通过 Module 赋予了 Wasm 极高的集成度。对于计算密集型任务,这是 Node.js 或前端的终极提速手段。


六、 WebTransport API:HTTP/3 时代的实时通信

场景: 在你的监控系统中,如果有数万台设备在毫秒级上报数据,WebSocket 的 TCP 队头阻塞(Head-of-Line Blocking)会导致延迟堆积。

  • 具体用法:

    JavaScript

    // 建立基于 HTTP/3 QUIC 的连接
    const transport = new WebTransport("https://metrics.your-server.com:443");
    await transport.ready;
    
    // 发送不可靠(双向)流:适合对实时性要求极高、丢失一两帧也没关系的监控指标
    const writer = transport.datagrams.writable.getWriter();
    const data = new TextEncoder().encode(JSON.stringify({ cpu: 85 }));
    await writer.write(data);
    
  • 工程价值: 它基于 UDP,不仅比 WebSocket 更快,还支持多路复用。即使网络波动,其中一个流卡住了,也不会影响其他流。


七、 Intersection Observer API (V2):精准感知“真实可见性”

场景: 监控 SDK 的广告反欺诈,或者极高性能的长列表渲染。

  • 具体用法:

    JavaScript

    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        // isVisible 会检测该元素是否被其他元素遮挡,或者是否有滤镜/透明度导致看不见
        if (entry.isIntersecting && entry.isVisible) {
          sendMetric('element-real-view');
        }
      });
    }, {
      trackVisibility: true, // 开启真实可见性追踪
      delay: 100 // 延迟检测以减轻 CPU 压力
    });
    
    observer.observe(targetNode);
    
  • 工程价值: 它是实现“无感监控”的利器。相比于 V1,它能告诉你用户是否真的看到了元素,而不仅仅是元素在视口内。


八、 Compression Streams API:浏览器原生无损压缩

场景: 监控 SDK 在上报巨大的 JSON 日志(如数 MB 的错误堆栈)前,先在前端进行压缩。

  • 具体用法:

    JavaScript

    async function compressAndSend(data) {
      const stream = new Blob([JSON.stringify(data)]).stream();
      const compressedStream = stream.pipeThrough(new CompressionStream('gzip'));
    
      // 这里的 response 就是 Gzip 压缩后的二进制流
      const response = await new Response(compressedStream).blob();
      navigator.sendBeacon('/log', response);
    }
    
  • 工程价值: 彻底抛弃 pako.js 等三方库,减少了包体积,且利用浏览器原生能力,压缩效率更高。


九、 File System Access API:把 Web 应用变成本地工具

场景: 开发一个本地离线日志分析工具,直接读取并保存用户的 GB 级日志文件。

  • 具体用法:

    JavaScript

    async function openLogFile() {
      // 1. 获取文件句柄
      const [handle] = await window.showOpenFilePicker();
      const file = await handle.getFile();
    
      // 2. 像 Node.js 一样获取可写流
      const writable = await handle.createWritable();
      await writable.write("New Log Entry");
      await writable.close();
    }
    
  • 工程价值: 不再是 input type="file" 那种简单的“上传”,而是真正实现了对文件的双向读写


构建无障碍组件之Accordion Pattern

作者 anOnion
2026年2月19日 16:38

Accordion Pattern 详解:构建垂直堆叠的展开收起组件

Accordion(手风琴)是一种常见的交互组件,由垂直堆叠的可交互标题组成,每个标题包含一个内容部分的标题、摘要或缩略图。本文基于 W3C WAI-ARIA Accordion Pattern 规范,详解如何构建无障碍的 Accordion 组件。

一、Accordion 的定义与核心概念

Accordion 是一组垂直堆叠的交互式标题,每个标题都包含一个内容部分的标题、摘要或缩略图。标题作为控件,允许用户显示或隐藏其关联的内容部分。

Accordion 常用于在单个页面上呈现多个内容部分时减少滚动需求。

1.1 核心术语

  • Accordion Header(手风琴标题):内容部分的标签或缩略图,同时作为显示(在某些实现中也包括隐藏)内容部分的控件
  • Accordion Panel(手风琴面板):与手风琴标题关联的内容部分

在某些 Accordion 中,手风琴标题旁边始终可见额外的元素。例如,每个手风琴标题可能伴随一个菜单按钮,用于提供适用于该部分的操作访问。

二、WAI-ARIA 角色与属性

2.1 基本角色

每个手风琴标题的内容包含在具有 role="button" 的元素中。

2.2 标题层级

每个手风琴标题按钮包装在具有 role="heading" 的元素中,并设置适合页面信息架构的 aria-level 值:

  • 如果原生宿主语言具有隐式标题和 aria-level 的元素(如 HTML 标题标签),可以使用原生宿主语言元素
  • 按钮元素是标题元素内部的唯一元素
<!-- 手风琴标题 -->
<h3>
  <button aria-expanded="true" aria-controls="panel-1" id="accordion-header-1">
    第一部分标题
  </button>
</h3>

<!-- 手风琴面板 -->
<div id="panel-1" role="region" aria-labelledby="accordion-header-1">
  <p>第一部分的内容...</p>
</div>

2.3 状态属性

  • aria-expanded:如果与手风琴标题关联的面板可见,设置为 true;如果面板不可见,设置为 false
  • aria-controls:设置为包含手风琴面板内容的元素的 ID
  • aria-disabled:如果与手风琴标题关联的面板可见,且手风琴不允许折叠该面板,则设置为 true

2.4 区域角色(可选)

每个作为面板内容容器的元素可以具有 role="region"aria-labelledby,其值引用控制面板显示的按钮:

  • 避免在会创建过多地标区域的情况下使用 region 角色,例如在可以同时展开超过约 6 个面板的手风琴中
  • 当面板包含标题元素或嵌套手风琴时,region 角色对屏幕阅读器用户感知结构特别有帮助
<!-- 手风琴标题按钮 -->
<h3>
  <button aria-expanded="true" aria-controls="panel-1" id="header-1">
    面板标题
  </button>
</h3>

<!-- 手风琴面板内容 -->
<div role="region" aria-labelledby="header-1" id="panel-1">
  <p>面板内容...</p>
</div>

三、键盘交互规范

3.1 基本键盘操作

按键 功能
Enter 或 Space 当焦点位于折叠面板的手风琴标题上时,展开关联面板。如果实现只允许一个面板展开,且另一个面板已展开,则折叠该面板
Tab 将焦点移动到下一个可聚焦元素;手风琴中的所有可聚焦元素都包含在页面 Tab 序列中
Shift + Tab 将焦点移动到上一个可聚焦元素;手风琴中的所有可聚焦元素都包含在页面 Tab 序列中

3.2 可选键盘操作

按键 功能
Down Arrow 如果焦点在手风琴标题上,将焦点移动到下一个手风琴标题。如果焦点在最后一个手风琴标题上,要么不执行任何操作,要么将焦点移动到第一个手风琴标题
Up Arrow 如果焦点在手风琴标题上,将焦点移动到上一个手风琴标题。如果焦点在第一个手风琴标题上,要么不执行任何操作,要么将焦点移动到最后一个手风琴标题
Home 当焦点在手风琴标题上时,将焦点移动到第一个手风琴标题
End 当焦点在手风琴标题上时,将焦点移动到最后一个手风琴标题

四、实现方式

4.1 基础结构

<div class="accordion">
  <!-- 第一部分 -->
  <h3>
    <button 
      aria-expanded="true" 
      aria-controls="section1"
      id="accordion-header-1">
      第一部分标题
    </button>
  </h3>
  <div 
    id="section1" 
    role="region" 
    aria-labelledby="accordion-header-1">
    <p>第一部分的内容...</p>
  </div>

  <!-- 第二部分 -->
  <h3>
    <button 
      aria-expanded="false" 
      aria-controls="section2"
      id="accordion-header-2">
      第二部分标题
    </button>
  </h3>
  <div 
    id="section2" 
    role="region" 
    aria-labelledby="accordion-header-2"
    hidden>
    <p>第二部分的内容...</p>
  </div>
</div>

4.2 单展开模式

在单展开模式下,一次只能展开一个面板:

<div class="accordion" data-accordion-single>
  <h3>
    <button 
      aria-expanded="true" 
      aria-controls="panel-1"
      aria-disabled="true">
      始终展开的面板
    </button>
  </h3>
  <div id="panel-1" role="region">
    <p>此面板无法折叠...</p>
  </div>
  
  <h3>
    <button 
      aria-expanded="false" 
      aria-controls="panel-2">
      可切换的面板
    </button>
  </h3>
  <div id="panel-2" role="region" hidden>
    <p>点击上方标题可展开此面板...</p>
  </div>
</div>

4.3 多展开模式

在多展开模式下,可以同时展开多个面板:

<div class="accordion" data-accordion-multiple>
  <h3>
    <button aria-expanded="true" aria-controls="multi-1">
      第一个面板
    </button>
  </h3>
  <div id="multi-1" role="region">
    <p>第一个面板内容...</p>
  </div>
  
  <h3>
    <button aria-expanded="true" aria-controls="multi-2">
      第二个面板(也可同时展开)
    </button>
  </h3>
  <div id="multi-2" role="region">
    <p>第二个面板内容...</p>
  </div>
</div>

4.4 使用原生 HTML <details> + name 实现

HTML5.2 起,<details> 元素支持 name 属性,可以实现原生的单展开模式(Accordion 效果),无需 JavaScript:

<details name="accordion-group" open>
  <summary>第一部分标题</summary>
  <p>第一部分的内容...</p>
</details>

<details name="accordion-group">
  <summary>第二部分标题</summary>
  <p>第二部分的内容...</p>
</details>

<details name="accordion-group">
  <summary>第三部分标题</summary>
  <p>第三部分的内容...</p>
</details>
关键点说明
特性 说明
name 属性 相同 name 值的 <details> 元素会互斥,实现单展开
open 属性 指定默认展开的面板
浏览器支持 Chrome 120+, Firefox, Safari 17.1+
增强版实现(添加 heading 结构)

⚠️ 注意<details> 元素的实现方式与 W3C Accordion Pattern 的 DOM 结构要求不完全一致。W3C 标准要求按钮元素必须是 heading 元素内部的唯一子元素(<h3><button>...</button></h3>),而 <details> 使用 <summary> 作为交互元素。

如果需要更好的无障碍支持,可以在 <summary> 内添加标题:

<details name="accordion-group" open>
  <summary>
    <h3 style="display: inline; font-size: inherit;">第一部分标题</h3>
  </summary>
  <p>第一部分的内容...</p>
</details>

重要提示:这种结构虽然添加了 heading,但仍然是 heading 在 summary 内部,与 W3C 要求的 button 在 heading 内部 的结构相反。因此,这种方式:

  • ✅ 提供了基本的标题层级信息
  • ❌ 不完全符合 W3C Accordion Pattern 的 DOM 结构规范
  • ❌ 可能不被某些屏幕阅读器正确识别为手风琴组件
适用场景

推荐使用 <details name>

  • 简单的 FAQ 页面
  • 不需要复杂样式的场景
  • 追求原生、轻量实现
  • 现代浏览器环境

推荐使用 W3C 模式:

  • 需要多展开模式
  • 需要箭头键导航
  • 需要精确的标题层级(SEO/屏幕阅读器)
  • 需要复杂的自定义样式

五、常见应用场景

5.1 表单分步填写

将长表单分成多个部分,用户逐步填写:

<div class="accordion">
  <h3>
    <button aria-expanded="true" aria-controls="step-1">
      步骤 1:个人信息
    </button>
  </h3>
  <div id="step-1" role="region">
    <label>姓名 <input type="text" /></label>
    <label>邮箱 <input type="email" /></label>
  </div>
  
  <h3>
    <button aria-expanded="false" aria-controls="step-2">
      步骤 2:地址信息
    </button>
  </h3>
  <div id="step-2" role="region" hidden>
    <label>城市 <input type="text" /></label>
    <label>邮编 <input type="text" /></label>
  </div>
</div>

5.2 FAQ 页面

常见问题解答页面,每个问题作为一个可展开的部分:

<div class="accordion">
  <h3>
    <button aria-expanded="false" aria-controls="faq-1">
      如何注册账户?
    </button>
  </h3>
  <div id="faq-1" role="region" hidden>
    <p>点击页面右上角的"注册"按钮,填写必要信息...</p>
  </div>
  
  <h3>
    <button aria-expanded="false" aria-controls="faq-2">
      如何重置密码?
    </button>
  </h3>
  <div id="faq-2" role="region" hidden>
    <p>点击登录页面的"忘记密码"链接...</p>
  </div>
</div>

5.3 设置面板

应用程序的设置页面,将相关设置分组:

<div class="accordion">
  <h3>
    <button aria-expanded="true" aria-controls="settings-general">
      通用设置
    </button>
  </h3>
  <div id="settings-general" role="region">
    <label><input type="checkbox" /> 启用通知</label>
    <label><input type="checkbox" /> 自动保存</label>
  </div>
  
  <h3>
    <button aria-expanded="false" aria-controls="settings-privacy">
      隐私设置
    </button>
  </h3>
  <div id="settings-privacy" role="region" hidden>
    <label><input type="checkbox" /> 公开个人资料</label>
    <label><input type="checkbox" /> 允许搜索</label>
  </div>
</div>

六、最佳实践

6.1 语义化标记

  • 使用适当的标题层级(h1-h6)包装手风琴标题按钮
  • 为每个面板添加 role="region" 以增强结构感知(面板数量较少时)
  • 确保按钮元素是标题元素内部的唯一元素

6.2 键盘导航

  • 实现基本的 Enter/Space 和 Tab 导航
  • 可选实现箭头键导航以提升用户体验
  • 确保所有手风琴标题都包含在 Tab 序列中

6.3 视觉指示

  • 使用清晰的视觉指示器表示展开/折叠状态
  • 为当前聚焦的标题提供明显的焦点样式
  • 考虑使用动画过渡提升用户体验

6.4 状态管理

  • 明确区分单展开和多展开模式
  • 在单展开模式中,考虑是否允许所有面板同时折叠
  • 使用 aria-disabled 表示不允许折叠的面板

6.5 嵌套考虑

  • 避免过深的嵌套层级
  • 嵌套手风琴时,确保每个层级有清晰的视觉区分
  • 考虑使用不同的标题层级表示嵌套关系

七、Accordion 与 Disclosure 的区别

特性 Accordion Disclosure
内容组织 多个垂直堆叠的面板 单个内容块
展开模式 支持单展开或多展开 独立控制
标题结构 使用 heading + button 结构 简单按钮或 summary
导航支持 支持箭头键导航 基本 Tab 导航
用途 表单分步、设置面板、FAQ 详细信息展示

八、总结

构建无障碍的 Accordion 组件需要关注三个核心:正确的语义化标记(heading + button 结构)、完整的键盘交互支持(包括可选的箭头键导航)、清晰的状态管理(aria-expanded、aria-controls、aria-disabled)。与简单的 Disclosure 不同,Accordion 强调多个面板的组织和管理,适用于更复杂的内容展示场景。

遵循 W3C Accordion Pattern 规范,我们能够创建既美观又包容的手风琴组件,为不同能力的用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

❌
❌