普通视图

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

移动web开发常见问题

作者 Crystal328
2026年1月4日 16:23

1. 去掉图片下方的空白间隙

默认情况下,图片会与其下方的元素产生一段空白的间隙

<img>默认是inline(行内替换元素),遵循基线对齐,下方预留文字下行空间;

image.png

<style>
  .box img {
    width: 200px;
    border: 1px solid red;
    /*1. 添加此样式*/
    vertical-align: top;
    /*2. 或者转为块级元素*/
    display:black;
  }
  .box p {
    margin: 0;
    height: 20px;
    background-color: khaki;
  }
</style>

<div class="box">
  <img src="./images/pic1.png" alt="" />
  <p></p>
</div>
  1. 清除父元素的行高影响图片的基线对齐会受父元素 line-height 影响,将父元素行高设为 0,也能消除间隙:
   .img-parent { /* .img-parent 是 img 的父容器 */
     line-height: 0;
   }

2. 元素宽高等比缩放

GIF2025-7-816-26-47.0becbb02.gif

利用 padding-top 来撑开元素的高,padding-top 百分比是相对于当前元素的父元素宽而言,而当前元素宽与父元素宽一样,则相当于元素的高是相对于当前元素的宽而言。

<style>
 .box {
    width: 25%;
  }
 .box .bl16-9 {
    --bl: calc(9 / 16 * 100%);
    padding-top: var(--bl);
    background-color: khaki;
    position: relative;
  }
  .box .content {
    position: absolute;
    inset: 0;
  }
</style>

<!-- 元素宽高比为 16:9 等比缩放 -->
<div class="box">
  <div class="bl16-9">
    <div class="content">元素宽高比为 16:9 等比缩放</div>
  </div>
</div>

3. 图片等比缩放

GIF2025-7-816-24-32.28c3c218.gif

在实际的网站上线后,数据都是从后台读取的,用户在上传图片尺寸时,并不一定会按照设计师设计的比例来上传,这样就会造成图片上传后,大小不一样。

  • 图片高度过小,会在下面留下空白
  • 图片高度过大,会有一部分下面的内容看不到
  • 我们实际在设计一张图时,重要的内容会在中间显示,所以最理想的效果是让图片能水平垂直居中于容器中

要实现图片按一定的宽高比等比缩放,可以先将图片的直接父元素实现宽高等比缩放,然后再给图片添加如下代码

img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
<style>
  .box {
    width: 25%;
  }
  .box .bl16-9 {
    --bl: calc(9 / 16 * 100%);
    padding-top: var(--bl);
    background-color: khaki;
    position: relative;
  }
  .box .content {
    position: absolute;
    inset: 0;
  }
  .box img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
</style>

<!-- 元素宽高比为 16:9 等比缩放 -->
<div class="box">
  <div class="bl16-9">
    <div class="content">
      <img src="./images/pic1.png" alt="" />
    </div>
  </div>
</div>

4. 背景图等比缩放

GIF-2022-8-16-18-50-31.f89069c2.gif

  • 当背景图片的宽高比与容器的宽高比不一样时
  • 我们希望不管容器宽高如何缩放,图片一直填充整个容器,然后水平垂直居中显示
/* 背景图填充整个容器 */
background-size: cover;
<style>
  body {
    margin: 0;
  }
  .box {
    width: 100vw;
    height: 31.467vw;
    /* 背景图片  不重复  水平垂直居中显示 */
    background: url(./images/banner1-@2x.png) no-repeat center;
    /* 背景图填充整个容器 */
    background-size: cover;
  }
</style>
<body>
  <div class="box"></div>
</body>

5. 2倍精灵图使用

  • 精灵图采用的是 2 倍图
  • 所以在处理精灵图时,我们需要通过 background-size: 50% auto; ,来将背景图片大小缩小一半
  • 测量尺寸时,也需要按一半的大小来测量

image.png

<style>
  .icon-play {
    width: 26px;
    height: 26px;
    border: 1px solid red;
    background-image: url(images/sprite.png);
    background-position: -36px -29px;
    background-size: 200px;
  }
</style>
<body>
  <div class="icon-play"></div>
</body>

6.经典的 1 像素问题

6.1、何为 1 像素问题

PSD 设计稿

  • 我们的设计稿是以 750px 宽来设计的,而我们实际开发时,代码是按 375px 来的。
  • 在 750px 设计稿中的 1px,按我们实际的开发代码来说,要折半,折成 0.5px 才对。
  • 但是不同手机上,不同浏览器对小数的处理是不一样的
  • 0.5px 在一些老的 IOS 和 Android 设备上不支持,他会把低于 0.5px 当成 0 来处理,>= 0.5px 当成 1px 来显示。
  • IOS 上会把>= 0.75px 的当作 1px 来处理 ,< 0.75 当成 0.5px 来处理 < 0.5px 当成 0 来处理
  • 而且 IOS 上,用 height: 1px 来代替 border-bottom: 1px solid red;测出的效果不同
  • 不同的手机上,效果不一样,具体以真机测试为主
  • 所以直接把代码折半,设置成 0.5px 显然是达不到目的。

1px 实际显示的大小

1px 在 dpr (像素比)不同时,其显示的大小不同,如下

image.png 所以在不同 dpr 下,显示的 1px 的线的宽度是下面图标出的蓝色的高度大小(1 物理像素大小) image.png 因此, 1px 像素问题(用当前设备的物理像素的1px进行渲染这根线的高度),本质上不是问题,如果公司觉得没有必要,也就不用处理。
如果公司认为就是要用设备能显示的最细的那个小方格显示,那我们就要处理这个问题。

6.2、1px 像素解决方案(最优的解决方案 transform+伪元素来实现)

实现原理:

  • 利用伪元素来绘制 1px 的线条,然后利用定位,把线条放在对应位置
  • 利用 media 查询判断不同的设备像素比对线条进行缩放
<style>
  .box {
    height: 50px;
    margin: 0 auto;
    position: relative;
  }
  .border-1px::before {
    position: absolute;
    content: "";
    height: 1px;
    width: 100%;
    background-color: red;
    bottom: 0;
    /* transform: scaleY(0.5); */
    /* 变换原点 */
    transform-origin: 50% 0%;
  }
  /* dpr=2,需要缩放一半,即0.5 */
  @media only screen and (-webkit-min-device-pixel-ratio: 2) {
    .border-1px:before {
      transform: scaleY(0.5);
    }
  }
  /* dpr=3,需要缩放到1/3,即0.33 */
  @media only screen and (-webkit-min-device-pixel-ratio: 3) {
    .border-1px:before {
      transform: scaleY(0.33);
    }
  }
</style>
<body>
  <div class="box border-1px"></div>
</body>

也可以通过 js 来判断 dpr,然后给元素添加对应的 Class 名字,来实现

if (window.devicePixelRatio && devicePixelRatio >= 2) {
  document.querySelector(".box").className = "border-1px";
}
昨天以前首页

祝大家 2026 年新年快乐,代码无 bug,需求一次过

作者 前端Hardy
2025年12月31日 18:05

新年将至,你是否想为亲友制作一个特别的新年祝福页面?今天我们就来一起拆解一个精美的 2026 新年祝福页面的完整实现过程。这个页面包含了加载动画、动态倒计时、雪花特效、悬浮祝福卡片等炫酷效果,完全使用 HTML、CSS 和 JavaScript 实现。


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

演示效果

演示效果

代码讲解

雪花生成算法

function createSnowflake(container, index) {
  const snowflake = document.createElement('div');

  // 随机属性:大小、位置、时长
  const size = Math.random() * 5 + 2;
  const x = Math.random() * 100;
  const duration = Math.random() * 10 + 5;
  const delay = Math.random() * 5;

  // 设置基础样式
  snowflake.classList.add('snowflake');
  snowflake.style.left = `${x}%`;
  snowflake.style.width = `${size}px`;
  snowflake.style.height = `${size}px`;
}

雪花飘落动画

snowflake.animate(
  [
    { transform: 'translate(0, -20px) rotate(0deg)' },
    { transform: `translate(${Math.sin(duration)*50}px, 100vh) rotate(90deg)` },
    { transform: `translate(${-Math.sin(duration)*25}px, 100vh) rotate(180deg)` },
    { transform: 'translate(0, 100vh) rotate(360deg)' },
  ],
  {
    duration: duration * 1000,
    delay: delay * 1000,
    iterations: Infinity,
    easing: 'linear'
  }
);
  • 使用 Math.sin()制造左右摆动的飘落路径
  • 为每个雪花设置不同的延迟和时长,增加真实感
  • 无限循环(Infinity)实现持续飘落
  • 响应式设计:根据屏幕宽度调整雪花数量

时间计算逻辑

function updateCountdown() {
  const now = new Date();
  const newYear = new Date(2026, 0, 1, 0, 0, 0, 0);
  const difference = newYear.getTime() - now.getTime();

  // 计算天、时、分、秒
  const days = Math.floor(difference / (1000 * 60 * 60 * 24));
  const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
  const minutes = Math.floor((difference / 1000 / 60) % 60);
  const seconds = Math.floor((difference / 1000) % 60);
}

数字格式化

// 两位数格式化
document.getElementById('seconds').textContent =
  String(seconds).padStart(2, '0');
  • 使用 setInterval(updateCountdown, 1000)实现秒级更新
  • padStart()确保始终显示两位数字
  • 防抖处理避免性能问题

自定义动画定义

@keyframes float {
  0% { transform: translateY(0px); }
  50% { transform: translateY(-15px); }
  100% { transform: translateY(0px); }
}

@keyframes glow {
  0%, 100% {
    text-shadow: 0 0 5px rgba(255, 215, 0, 0.5);
  }
  50% {
    text-shadow: 0 0 20px rgba(255, 215, 0, 0.8);
  }
}

一键复制源码

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

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>2026新年祝福 - 元旦快乐</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"
        crossorigin="anonymous" />
    <script src="https://cdn.tailwindcss.com"></script>
    <script>
        // Tailwind CSS 配置
        tailwind.config = {
            theme: {
                extend: {
                    colors: {
                        primary: '#ef4444',
                        secondary: '#f59e0b',
                    },
                    fontFamily: {
                        sans: ['Inter', 'system-ui', 'sans-serif'],
                    },
                },
            }
        }
    </script>
    <style type="text/tailwindcss">
        @layer utilities {
        .content-auto {
          content-visibility: auto;
        }
        .text-shadow-glow {
          text-shadow: 0 0 10px rgba(255, 215, 0, 0.7), 0 0 20px rgba(255, 215, 0, 0.5);
        }
        .animate-float {
          animation: float 4s ease-in-out infinite;
        }
        .animate-float-delay-1 {
          animation: float 4s ease-in-out 1s infinite;
        }
        .animate-float-delay-2 {
          animation: float 4s ease-in-out 2s infinite;
        }
        .animate-float-delay-3 {
          animation: float 4s ease-in-out 3s infinite;
        }
        .animate-pulse-soft {
          animation: pulseSoft 2s ease-in-out infinite;
        }
        .animate-fade-in {
          animation: fadeIn 1s ease-out forwards;
        }
        .animate-slide-up {
          animation: slideUp 1s ease-out forwards;
        }
        .animate-slide-up-delay-1 {
          animation: slideUp 1s ease-out 0.3s forwards;
        }
        .animate-slide-up-delay-2 {
          animation: slideUp 1s ease-out 0.6s forwards;
        }
        .animate-slide-up-delay-3 {
          animation: slideUp 1s ease-out 0.9s forwards;
        }
        .animate-scale-in {
          animation: scaleIn 0.5s ease-out forwards;
        }
        .animate-glow {
          animation: glow 2s ease-in-out infinite;
        }
      }

      @keyframes float {
        0% { transform: translateY(0px); }
        50% { transform: translateY(-15px); }
        100% { transform: translateY(0px); }
      }

      @keyframes pulseSoft {
        0% { opacity: 0.7; }
        50% { opacity: 1; }
        100% { opacity: 0.7; }
      }

      @keyframes fadeIn {
        from { opacity: 0; }
        to { opacity: 1; }
      }

      @keyframes slideUp {
        from { opacity: 0; transform: translateY(50px); }
        to { opacity: 1; transform: translateY(0); }
      }

      @keyframes scaleIn {
        from { opacity: 0; transform: scale(0.9); }
        to { opacity: 1; transform: scale(1); }
      }

      @keyframes glow {
        0%, 100% { text-shadow: 0 0 5px rgba(255, 215, 0, 0.5); }
        50% { text-shadow: 0 0 20px rgba(255, 215, 0, 0.8); }
      }

      /* 全局样式 */
      html, body {
        height: 100%;
        overflow-x: hidden;
        scroll-behavior: smooth;
      }

      body {
        margin: 0;
        padding: 0;
      }

      /* 雪花样式 */
      .snowflake {
        position: absolute;
        background-color: white;
        border-radius: 50%;
        pointer-events: none;
        z-index: 0;
      }

      /* 加载动画 */
      .loading-screen {
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: #dc2626;
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 9999;
        transition: opacity 0.8s ease-out;
      }

      .loading-hidden {
        opacity: 0;
        pointer-events: none;
      }
    </style>
</head>

<body class="bg-gradient-to-b from-red-800 via-red-700 to-red-900 text-white">
    <!-- 加载动画 -->
    <div id="loading-screen" class="loading-screen">
        <h1 class="text-4xl font-bold text-white" id="loading-text">2026</h1>
    </div>

    <!-- 主要内容容器 -->
    <div class="relative min-h-screen overflow-hidden">
        <!-- 雪花容器 -->
        <div id="snow-container" class="fixed inset-0 pointer-events-none z-0"></div>

        <!-- 装饰星星 -->
        <div class="absolute top-10 right-10 w-20 h-20">
            <div class="animate-float">
                <i class="fa-solid fa-star text-yellow-300 text-4xl animate-pulse-soft"></i>
            </div>
        </div>

        <div class="absolute bottom-20 left-10 w-16 h-16">
            <div class="animate-float-delay-1">
                <i class="fa-solid fa-star text-yellow-300 text-3xl animate-pulse-soft"></i>
            </div>
        </div>

        <!-- 主要内容 -->
        <div class="container mx-auto px-4 py-16 relative z-10">
            <!-- 标题部分 -->
            <div class="text-center mb-12 opacity-0 animate-slide-up">
                <h1
                    class="text-4xl sm:text-6xl md:text-7xl font-bold mb-4 bg-gradient-to-r from-yellow-300 to-yellow-500 bg-clip-text text-transparent animate-glow">
                    2026新年快乐!
                </h1>
                <p class="text-xl sm:text-2xl text-yellow-100 opacity-0" id="subtitle">
                    愿您新的一年里心想事成,万事如意
                </p>
            </div>

            <!-- 倒计时部分 -->
            <div class="text-center mb-16 opacity-0 animate-slide-up-delay-1">
                <h2 class="text-2xl sm:text-3xl font-semibold mb-6">距离2026年还有</h2>
                <div class="grid grid-cols-4 gap-4 sm:gap-8" id="countdown-container">
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="days">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="hours">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="minutes">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                    <div class="flex flex-col items-center opacity-0">
                        <div
                            class="text-3xl sm:text-5xl font-bold text-white bg-gradient-to-r from-red-500 to-amber-500 p-4 sm:p-6 rounded-lg shadow-lg min-w-[80px] sm:min-w-[120px] text-center">
                            <span id="seconds">00</span>
                        </div>
                        <div class="mt-2 text-white text-xs sm:text-sm"></div>
                    </div>
                </div>
            </div>

            <!-- 祝福卡片部分 -->
            <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-16 opacity-0 animate-slide-up-delay-2">
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:rotate-1 cursor-pointer opacity-0"
                    id="wish-card-1">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">身体健康,万事如意</p>
                    </div>
                </div>
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:-rotate-1 cursor-pointer opacity-0"
                    id="wish-card-2">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">事业有成,财源广进</p>
                    </div>
                </div>
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:-rotate-1 cursor-pointer opacity-0"
                    id="wish-card-3">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">家庭幸福,平安顺遂</p>
                    </div>
                </div>
                <div class="bg-white/10 backdrop-blur-md p-6 rounded-xl border border-white/20 shadow-xl hover:shadow-yellow-500/20 transition-all transform hover:scale-105 hover:rotate-1 cursor-pointer opacity-0"
                    id="wish-card-4">
                    <div class="flex items-center space-x-4">
                        <div class="text-yellow-400 text-3xl">
                            <i class="fa-solid fa-heart"></i>
                        </div>
                        <p class="text-xl font-medium">心想事成,吉祥如意</p>
                    </div>
                </div>
            </div>

            <!-- 新年图片 -->
            <div
                class="relative mx-auto max-w-2xl rounded-2xl overflow-hidden shadow-2xl mb-16 opacity-0 animate-slide-up-delay-3">
                <img src="https://space.coze.cn/api/coze_space/gen_image?image_size=landscape_16_9&prompt=New%20Year%202026%2C%20celebration%2C%20fireworks%2C%20happy%20people%2C%20chinese%20new%20year%20style&sign=7480eb84f78aa7d9bd5edaf5dc5aad19"
                    alt="2026新年庆祝"
                    class="w-full h-auto rounded-2xl transform transition-transform hover:scale-105 duration-700" />
                <div class="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent flex items-end">
                    <p class="text-white p-6 text-lg">祝大家2026年元旦快乐!</p>
                </div>
            </div>

            <!-- 按钮部分 -->
            <div class="flex flex-wrap justify-center gap-4 opacity-0 animate-slide-up-delay-3">
                <button
                    class="bg-gradient-to-r from-yellow-500 to-amber-500 text-red-900 font-bold py-3 px-8 rounded-full text-lg shadow-lg transform transition-transform duration-300 hover:scale-105 hover:shadow-lg hover:shadow-yellow-500/20 active:scale-95"
                    id="share-button">
                    <i class="fa-solid fa-share-alt mr-2"></i>分享祝福
                </button>
                <button
                    class="bg-transparent border-2 border-yellow-500 text-yellow-500 font-bold py-3 px-8 rounded-full text-lg transform transition-all duration-300 hover:bg-yellow-500/10 hover:scale-105 active:scale-95"
                    id="music-button">
                    <i class="fa-solid fa-music mr-2"></i>播放音乐
                </button>
            </div>

            <!-- 底部装饰 -->
            <div class="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-red-900 to-transparent opacity-0 animate-fade-in"
                id="bottom-decoration"></div>

            <!-- 底部文字 -->
            <footer class="text-center mt-20 text-white/60 text-sm opacity-0 animate-fade-in" id="footer">
                <p>© 2025 新年祝福页面 | 祝您新年快乐,阖家幸福</p>
            </footer>
        </div>
    </div>

    <script>
        // 页面加载动画
        document.addEventListener('DOMContentLoaded', function () {
            const loadingScreen = document.getElementById('loading-screen');
            const loadingText = document.getElementById('loading-text');
            const subtitle = document.getElementById('subtitle');
            const countdownItems = document.querySelectorAll('#countdown-container > div');
            const wishCards = document.querySelectorAll('[id^="wish-card-"]');
            const bottomDecoration = document.getElementById('bottom-decoration');
            const footer = document.getElementById('footer');

            // 加载文字动画
            loadingText.animate([
                { transform: 'scale(1)' },
                { transform: 'scale(1.2)' },
                { transform: 'scale(1)' }
            ], {
                duration: 1000,
                iterations: Infinity
            });

            // 500ms后隐藏加载屏幕,显示主内容
            setTimeout(() => {
                loadingScreen.classList.add('loading-hidden');

                // 显示副标题
                setTimeout(() => {
                    subtitle.classList.add('animate-fade-in');
                }, 500);

                // 显示倒计时项
                countdownItems.forEach((item, index) => {
                    setTimeout(() => {
                        item.classList.add('animate-scale-in');
                    }, 800 + index * 200);
                });

                // 显示祝福卡片
                wishCards.forEach((card, index) => {
                    setTimeout(() => {
                        card.classList.add('animate-scale-in');
                    }, 1600 + index * 200);
                });

                // 显示底部装饰和页脚
                setTimeout(() => {
                    bottomDecoration.classList.add('animate-fade-in');
                }, 2400);

                setTimeout(() => {
                    footer.classList.add('animate-fade-in');
                }, 2800);
            }, 1000);

            // 初始化倒计时
            updateCountdown();
            setInterval(updateCountdown, 1000);

            // 初始化雪花效果
            createSnowflakes();

            // 按钮事件处理
            document.getElementById('share-button').addEventListener('click', function () {
                alert('祝福已分享!');
            });

            document.getElementById('music-button').addEventListener('click', function () {
                alert('音乐播放功能即将上线!');
            });
        });

        // 倒计时功能
        function updateCountdown() {
            const now = new Date();
            const newYear = new Date(2026, 0, 1, 0, 0, 0, 0);
            const difference = newYear.getTime() - now.getTime();

            if (difference > 0) {
                const days = Math.floor(difference / (1000 * 60 * 60 * 24));
                const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
                const minutes = Math.floor((difference / 1000 / 60) % 60);
                const seconds = Math.floor((difference / 1000) % 60);

                document.getElementById('days').textContent = String(days).padStart(2, '0');
                document.getElementById('hours').textContent = String(hours).padStart(2, '0');
                document.getElementById('minutes').textContent = String(minutes).padStart(2, '0');
                document.getElementById('seconds').textContent = String(seconds).padStart(2, '0');
            } else {
                document.getElementById('days').textContent = '00';
                document.getElementById('hours').textContent = '00';
                document.getElementById('minutes').textContent = '00';
                document.getElementById('seconds').textContent = '00';
            }
        }

        // 雪花效果
        function createSnowflakes() {
            const snowContainer = document.getElementById('snow-container');
            const particleCount = window.innerWidth < 768 ? 20 : 50;

            for (let i = 0; i < particleCount; i++) {
                createSnowflake(snowContainer, i);
            }

            // 窗口大小改变时重新创建雪花
            window.addEventListener('resize', function () {
                while (snowContainer.firstChild) {
                    snowContainer.removeChild(snowContainer.firstChild);
                }
                const newParticleCount = window.innerWidth < 768 ? 20 : 50;
                for (let i = 0; i < newParticleCount; i++) {
                    createSnowflake(snowContainer, i);
                }
            });
        }

        function createSnowflake(container, index) {
            const snowflake = document.createElement('div');
            snowflake.classList.add('snowflake');

            // 随机属性
            const size = Math.random() * 5 + 2;
            const x = Math.random() * 100;
            const duration = Math.random() * 10 + 5;
            const delay = Math.random() * 5;
            const opacity = Math.random() * 0.5 + 0.3;

            // 设置样式
            snowflake.style.left = `${x}%`;
            snowflake.style.top = '-20px';
            snowflake.style.width = `${size}px`;
            snowflake.style.height = `${size}px`;
            snowflake.style.opacity = `${opacity}`;

            // 添加到容器
            container.appendChild(snowflake);

            // 创建动画
            snowflake.animate(
                [
                    { transform: 'translate(0, -20px) rotate(0deg)' },
                    { transform: `translate(${Math.sin(duration) * 50}px, 100vh) rotate(90deg)` },
                    { transform: `translate(${-Math.sin(duration) * 25}px, 100vh) rotate(180deg)` },
                    { transform: 'translate(0, 100vh) rotate(360deg)' },
                ],
                {
                    duration: duration * 1000,
                    delay: delay * 1000,
                    iterations: Infinity,
                    easing: 'linear'
                }
            );
        }
    </script>
</body>

</html>

祝大家 2026 年新年快乐,代码无 bug,需求一次过!

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

《网页布局速通:8 大主流方案 + 实战案例》-pink老师现代网页布局总结

作者 王小菲
2025年12月29日 13:37

一、概述与目标

CSS 布局是网页设计的核心技术,主要用于控制页面元素的排列与呈现方式。目前主流的布局方案包括常规文档流布局、模式转换布局、弹性布局(Flexbox)、定位布局、网格布局(Grid)和多列布局。

接下来我们会逐一拆解它们的优缺点与适用场景,帮你快速看懂主流官网的布局实现思路。

二、常规文档流布局

这是浏览器的默认排版,是 CSS 布局的基础,页面大结构依靠块元素上下堆叠实现。包含块元素和行内元素,文档流方向默认从上到下、从左到右排列。

块元素(block) 独占一行,宽度默认撑满容器;可设置宽高,呈垂直排列;举例:div、p、h1~h6
行内元素(inline) 水平依次排列,容器宽度不足则换行;宽高由内容决定,无法直接设置;举例:span、img、strong

image.pngimage.png

三、模式转换布局

image.pngimage.png

如上图所示,需求要求我们把块级盒子展示为一行,或者要求行内元素有更大的点击范围,我们改怎么办呢?

那么就需要用到display转换, 我们可以将上面两种元素的display属性设置为inline-block, 可实现上述效果

image.pngimage.png

display转换为 inline-block后,可以设置宽高,又不用独占一行,这种特点让它可以广泛应用于让块级盒子一行显示或让行内盒子具备宽高的场景

属性值 是否独占一行 能否设置宽高 默认宽度
display: block ✔️ 撑满容器宽度
display: inline 由内容决定
display: inline-block ✔️ 由内容决定(可覆盖)

但是使用行内块元素需要注意: 元素间会有空隙,需要给父元素设font-size: 0,因此适合对间距要求不高的场景,如果精细排版建议用 Flex或Grid。

image.png

四、被逐渐替代的float

float最早是做”文字环绕”效果的,如下图所示

image.png

float可以让元素脱离文档流向左或向右浮动, 但这会导致父容器高度塌陷,从而影响周围元素的布局,例如下图1所示。而很多时候我们是不能给父容器规定高度的,它的高度取决于后台服务返回的数据量,例如京东的这个商品列表展示,随着鼠标的滚动,商品不断增多,高度不断增加,这个时候我们怎么办呢?

image.pngimage.png

这个时候我们就要进行清除浮动了,主要有以下四种方法

1、双伪元素清除浮动

image.png

2、单伪元素清除浮动

image.png

3、额外标签法:在浮动元素最后新增块级标签,但增加冗余标签

image.png

4、overflow 清除浮动:触发 BFC 包裹浮动元素
image.png

因为float问题太多, 要手动解决 “高度塌陷”,还得写额外代码清除浮动, 排版稍微复杂点就容易错位,对新手很不友好, 现在有更简单的 Flex/Grid 布局,又灵活又不存在上述问题,所以浮动就成 “时代的眼泪”了

五、弹性布局

Flexbox是Flexible Box Layout Module(弹性盒子布局模块)的缩写,可以快速实现元素的对齐、分布和空间分配。例如京东、淘宝、小米等主流网站都使用了flex布局,而且我们的低代码平台也可以设置元素为flex布局

image.pngimage.pngimage.png

我们为啥要使用flex布局呢?

以B站头部为例,想要实现下图的效果,三个块级元素并排在一行,实现两端对齐的效果,用之前的办法,可能要变成行内块、给margin或者padding来实现,或者干脆采用浮动的办法,那么实现垂直居中该怎么办呢?

垂直居中是传统布局的 “老大难”,有的同学可能说使用line-height,但是line-height是无法让块级的盒子垂直居中,这个时候我们可以使用flex,只需要三行代码(display: flex;align-items: center;justify-content: space-between;)就可以实现B站头部的布局效果,我们公司的官网头部也是类似的实现方案

image.pngimage.png

1、flex布局的核心

父控子:父盒子控制子盒子如何排列布局(父盒子称为容器,子盒子称为项目),控制属性要写在父元素身上;

轴方向:主轴默认水平、交叉轴默认垂直,可自定义。

2、flex的属性

父盒子属性

属性 作用说明 所有可选值
display 定义元素为 Flex 容器 flex
flex-direction 定义主轴方向(项目排列方向) row(默认,水平从左到右)、row-reverse(水平从右到左)、column(垂直从上到下)、column-reverse(垂直从下到上)
flex-wrap 控制项目是否换行 nowrap(默认,不换行)、wrap(换行,第一行在上)、wrap-reverse(换行,第一行在下)
justify-content 定义主轴上的对齐方式(项目整体分布) flex-start(默认,靠主轴起点)、flex-end(靠主轴终点)、center(居中)、space-between(两端对齐,项目间间距相等)、space-around(项目两侧间距相等)、space-evenly(项目间间距完全相等)
align-items 定义交叉轴上的对齐方式(单行时项目整体对齐) stretch(默认,拉伸填满容器)、flex-start(靠交叉轴起点)、flex-end(靠交叉轴终点)、center(垂直居中)、
align-content 定义多行时交叉轴上的对齐方式(仅当 flex-wrap: wrap 且内容换行时生效) stretch(默认,拉伸填满容器)、flex-start(靠交叉轴起点)、flex-end(靠交叉轴终点)、center(居中)、space-between(两端对齐)、space-around(项目行两侧间距相等)

项目属性:

属性 作用说明 所有可选值 / 取值规则
order 定义项目的排列顺序(默认 0,数值越小越靠前) 任意整数(正整数 / 负整数 / 0),无单位
flex-grow 定义项目的放大比例(默认 0,即不放大) 非负数字(0 / 正小数 / 正整数),无单位;数值越大,占剩余空间比例越高
flex-shrink 定义项目的缩小比例(默认 1,空间不足时等比缩小) 非负数字(0 / 正小数 / 正整数),无单位;设为 0 则空间不足时不缩小
flex-basis 定义项目在主轴方向上的初始大小(优先级高于 width/height) 1. 长度值(px/em/rem/% 等);2. auto(默认,取项目自身宽高);3. content(按内容自适应)
flex flex-grow、flex-shrink、flex-basis 的简写 1. 常用简写:- flex: 1 → 等价于 flex: 1 1 auto- flex: auto → 等价于 flex: 1 1 auto- flex: none → 等价于 flex: 0 0 auto2. 完整写法:flex:
align-self 覆盖容器的 align-items,单独定义某个项目的交叉轴对齐方式 auto(默认,继承容器 align-items)、stretch、flex-start、flex-end、center、baseline

3、使用场景

3.1实现基础横向并排 + 垂直居中(导航栏核心效果)

3 个子元素水平并排,且在父盒子中垂直居中(对应 B 站头部核心布局)

image.png

    /* 父容器(控制子元素) */
    .container {
     ...
      display: flex; /* 开启Flex */
      align-items: center; /* 交叉轴(垂直)居中 */
      ...
    }
  
3.2实现横向两端对齐(导航栏左右分布效果)

logo 居左、登录按钮居右,且两者都垂直居中(网页头部通用布局)。

image.png

  .container {
      ...
      display: flex;
      align-items: center;
      justify-content: space-between; /* 主轴(水平)两端对齐 */
     ...
    }
3.3实现横向平均分布(卡片列表效果)

3 个卡片水平平均分布,间距一致(商品列表 / 功能入口常用)。

image.png

  .container {
      ...
    display: flex;
      align-items: center;
      justify-content: space-around; /* 主轴平均分布(项目两侧间距相等) */
     ...
    }
3.4实现垂直排列(侧边栏)

子元素垂直排列(更改主轴方向),且垂直居中(侧边栏核心布局)。

image.png

  .container {
      ...
     display: flex;
      flex-direction: column; /* 更改主轴为垂直方向 */
      justify-content: center; /* 主轴(垂直)居中 */
      gap: 10px; /* 项目间距(替代margin) */
     ...
    }
3.5实现自动换行(响应式卡片)

元素超出父容器宽度自动换行(响应式布局核心)。

image.png

  .container {
      ...
     width: 800px;
     display: flex;
      flex-wrap: wrap; /* 超出容器宽度自动换行 */
      gap: 15px;
     ...
    }

 .item {
      width: 220px;
      height: 120px;
      ...
    }
3.6实现子元素占满剩余空间(搜索框布局)

搜索框自动占满左右元素的剩余空间(网页搜索栏通用布局)。

image.png

 .container {
      width: 800px;
      height: 80px;
      border: 1px solid #ccc;
      display: flex;
      align-items: center;
        ...
    }
    .left {
      width: 80px;
      height: 40px;
       ...
    }
    .search {
      flex: 1; /* 占满主轴剩余空间 */
      height: 40px;
      ...
    }
    .right {
      width: 80px;
      height: 40px;
      line-height: 40px;
     ...
    }
3.7实现整体居中(登录框 / 弹窗)

在页面中水平 + 垂直居中

image.png

body {
      margin: 0;
      height: 100vh; /* 占满视口高度 */
      display: flex;
      justify-content: center; /* 水平居中 */
      align-items: center; /* 垂直居中 */
       ...
    }
    .login-box {
      width: 400px;
      height: 300px;
      line-height: 300px;
        ...
    }
3.8实现自定义子元素顺序

元素显示顺序为 菜单 2 → 菜单 3 → 菜单 1(无需修改 HTML 结构,仅通过 CSS 调整)。

image.png

  .container {
      ...
    display: flex;
      align-items: center
     ...
    }
.item {
      width: 100px;
      height: 60px;
      ...
    }
    /* 自定义顺序(默认0,数值越小越靠前) */
    .item1 { order: 3; }
    .item2 { order: 1; }
    .item3 { order: 2; }

4、真实应用场景

4.1 百度图片-模仿瀑布流效果

image.pngimage.png

五个块级列容器通过 Flex 水平均分排列(各占父容器 1/5 宽度),每个列容器内垂直排布图片、按钮等内容。

4.2 京东-无限滚动展示商品列表 image.pngimage.png

父容器设 Flex 并允许换行,子元素通过媒体查询 + 宽高限制,实现不同屏幕下自动调整每行展示数量,超出则换行。

淘宝也跟京东一样,使用flex布局来实现的无限滚动展示商品,但是如果你需要更复杂的响应式布局,需精准控制行列、页面多模块分区时就要使用grid了

六、定位布局

定位布局是控制页面元素位置的核心技术,能实现元素脱离文档流、层叠、固定位置等效果。 例如下图中B站首页,很多效果都是使用定位布局实现的。

image.png

常见场景:

固定导航栏:页面滚动时,导航栏始终固定在视口顶部

吸顶效果:元素滚动到特定位置后固定

弹出 / 下拉菜单:鼠标悬浮时显示

悬浮效果:元素浮在其他元素上方

定位分类

  • 相对定位:元素相对自身原位置偏移,不脱离文档流,保留原占位
  • 绝对定位:元素相对最近的已定位父元素偏移,完全脱离文档流,不保留占位
  • 固定定位:元素相对浏览器视口固定,脱离文档流,滚动页面时位置不变
  • 粘性定位:元素在滚动到指定阈值前是相对定位,之后变为固定定位,结合两者特性

1、 场景一:子绝父相实现购物车效果

为什么用 “子绝父相”?

子元素用绝对定位:能浮在上方,且不占位置、不影响其他元素布局,而父元素用相对定位,让子元素能跟着父元素移动(作为定位参考),同时父元素保留原占位、不影响其他布局,例如下图。

image.png

<style>
    /* 父元素:购物车按钮(相对定位) */
    .cart-btn {
      position: relative; /* 父相 */
    ...
    }

    /* 子元素:数量标记(绝对定位) */
    .cart-count {
      position: absolute; /* 子绝 */
      top: -5px; /* 向上偏移 */
      right: -5px; /* 向右偏移 */
      width: 18px;
      height: 18px;
      ...
    }
  </style>
 <button class="cart-btn">
    我的购物车
    <span class="cart-count">3</span>
  </button>

小米官网swiper组件左右翻页的箭头也是采用子绝父相的做法,将左右箭头先使用top调整到50%的高度,然后再使用margin-top往上调整为自身高度的一半,从而实现在swiper中垂直居中效果,如下图所示

image.png

2、 场景二:固定定位实顶部导航栏和侧边悬浮导航

例如下图中官网导航栏和右侧悬浮按钮,就是使用固定定位实现的

image.pngimage.png

3、 场景三:粘性定位实现低代码卡片 tab 标签页吸顶效果

image.pngimage.png

七、网格布局

网格布局是二维布局模型,通过定义行(rows)和列(columns),精准控制网页元素的位置、尺寸,还能实现响应式设计。

网格布局具有上述优势,我们是不是可以抛弃弹性布局,全部使用网格布局呢?

事实上,实际开发中 flex 和 grid 常混用:

Flex:适合快速做一维布局、动态对齐内容(比如单行布局) 等线性排列场景

Grid:适合搭建复杂页面框架,可同时控制行和列的排列,实现真正的二维布局。

例如下图中B站首页布局就是 flex 和 grid 混用实现的

image.png

场景1:实现B站11列2行竖向排列导航栏效果,同时控行列

  /* 1列2行,竖向排列 */
    .bilibili-nav {
 ...
      display: grid;
      /* 核心:列优先排列(竖向填充) */
      grid-auto-flow: column;
      /* 定义2行(每行高度均分) */
      grid-template-rows: repeat(2, 1fr);
      /* 定义11列(每列宽度均分) */
      grid-template-columns: repeat(11, 1fr);
  ...
    }

image.png

场景2:实现阿里巴巴矢量图标库响应式卡片布局(适配手机 / 平板 / PC)

如下图效果,可以直接使用grid布局实现,无须借助媒体查询

...
    /* 卡片网格容器 */
    .card-grid {
      display: grid;
      gap: 20px; /* 卡片之间的水平+垂直间距(无需margin,避免重叠) */
      /* 核心:自动适配列数,列宽最小250px,最大自适应 */
      grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
    }
...
 

image.pngimage.pngimage.png

image.pngimage.png

场景3:实现蔚来汽车官网“2 行 3 列 + 汽车图跨 2 列”效果

...
    /* 红框网格容器 */
    .nio-grid-container {
      display: grid;
      /* 行列比例:匹配2行3列+大元素跨列 */
      grid-template-columns: 2fr 1fr 1fr; 
      grid-template-rows: 1fr 1fr;
...
    }

    /* 1. 汽车图(跨1行2列) */
    .item-car {
      grid-area: 1 / 1 / 2 / 3; /* 行1-2,列1-3 → 跨2列 */
    }
    /* 2. 右上角“生长” */
    .item-grow {
      grid-area: 1 / 3 / 2 / 4;
    }

    /* 3. 中间右侧“11” */
    .item-11 {
      grid-area: 2 / 3 / 3 / 4;
    }

    /* 4. 左下角元素 */
    .item-left-bottom {
      grid-area: 2 / 1 / 3 / 2;
    }

    /* 5. 中间下元素 */
    .item-middle-bottom {
      grid-area: 2 / 2 / 3 / 3;
    }
  </style>

image.pngimage.pngimage.png

简单来说,Grid 是 “为复杂二维布局而生”,能以更少的代码实现更灵活、可控的布局,尤其适合页面框架、响应式卡片、复杂图文组合等场景。

八、多列布局

用于将元素内容自动分割为指定数量的垂直列,如下图效果。有些同学可能会说,下面的布局我们用flex或者grid也能做出来,那么为什么要再学习多列布局呢

因为如果使用flex或者grid布局,我们需要先准备三个盒子,然后再把内容装进去,而使用多列布局则不需要事先准备盒子,直接准备内容就可以了,如下代码所示

image.png

 /* 容器:设置多列 */
    .column-container {
      ...
      /* 多列核心属性 */
      column-count: 3; /* 分为3列 */
      column-gap: 10px; /* 列之间的间隙 */
      column-rule: 2px solid #4da6ff; /* 列分隔线 */
       ...
    }
    /* 子元素:不同高度模拟不规则布局 */
    .item {
      ...
      break-inside: avoid; /* 避免子元素被列分割 */
      ...
    }

适用场景

  1. 长文章分栏:文章自动分列,支持间隙、响应式效果,如语雀官网效果
  2. 图片瀑布流,如阿里巴巴矢量图标库

image.pngimage.png

九、总结

不同技术各有适用场景、优缺点,需配合使用:

  • 简单布局:优先用 Flexbox(一维)或 Grid(二维)
  • 复杂响应式布局:Grid + 媒体查询
  • 文本内容分栏:多列布局(column-count)
  • 兼容旧浏览器:浮动布局,或 Flexbox 降级方案
  • 趋势:CSS Grid 逐渐成为主流,适配更复杂布局场景

一次线上样式问题复盘:当你钻进 CSS 牛角尖时,问题可能根本不在 CSS

2025年12月30日 15:25

背景:一个看似很“典型”的样式问题

线上遇到一个样式问题:

页面底部的 footer 无法被撑到预期高度,看起来像是高度计算出了问题。

image.png

从表象看,这是一个非常典型的 CSS 问题

  • 高度没生效
  • 布局被压缩
  • 父子元素高度关系异常

于是我很自然地开始从「局部样式」入手排查。


第一阶段:在“正确但无效”的方向里打转

我的第一反应(相信很多前端都会)是:

  • 是不是 flex 没用对?
  • 是不是 height: 100% 没生效?
  • 是不是父容器没有明确高度?
  • 要不要改成 min-height
  • 会不会是 BFC / overflow 的问题?

于是我开始:

  • 反复调整 footer 和父容器的 CSS
  • 检查 DOM 结构
  • 对比正常和异常页面的样式差异

甚至还把问题丢给了 AI,希望从 CSS 角度找到一个“精确解法”。

👉 但问题是:这些分析逻辑本身都没错,却始终解决不了问题。


第二阶段:意识到自己可能“钻牛角尖了”

真正让我停下来的是一个感觉:

我已经在同一小块区域里反复验证假设,但没有任何实质进展。

这时候我意识到一个危险信号:

  • ❌ 我默认「问题一定在 footer 或它的直接父级」
  • ❌ 我默认这是一个“局部 CSS 失效问题”
  • ❌ 我不断在验证同一类假设

于是我强迫自己换了一个思路:

先不管 footer,看看整个页面的高度是怎么被算出来的。


第三阶段:把视野拉大,问题反而变简单了

当我从页面根节点开始往下看布局结构时,很快发现了一个异常点:

👉 table 容器被设置了 height: 50% 的固定比例高度

这件事的影响是:

  • table 本身高度被强行限制
  • 页面整体高度无法自然撑开
  • footer 即使写得再“正确”,也只能在剩余空间里挤着

而 footer “看起来没被撑高”,其实只是被上游布局截断了


真正的解决方案(非常简单)

/* 原本 */
.table-wrapper {
  height: 50%;
}

/* 修改后 */
.table-wrapper {
  height: auto; /* 或直接移除 */
}

复盘:这个问题真正难的地方是什么?

这个问题并不难,但它有几个很容易让人误判的点:

1️⃣ 表象非常像“footer 自身的问题”

下意识认为:

  • footer 写错了
  • 高度没生效
  • flex 布局有 bug

2️⃣ 局部样式逻辑是“自洽的”

CSS 写的没问题,AI 给的建议也没错,但:

在错误的前提下,所有正确的推导都是无效的。

3️⃣ 真正的问题在“更上游”

布局问题里,经常是:

  • 子元素异常
  • 但根因在祖先节点

Tailwind CSS v4 深度指南:目录架构与主题系统

2025年12月30日 11:49

Tailwind CSS v4 深度指南:目录架构与主题系统

本文详解 Tailwind CSS v4 的样式复用目录结构组织与 CSS-first 主题系统实现

前言

Tailwind CSS v4 是一次重大更新,引入 CSS-first 配置方式和全新的 Oxide 引擎,性能提升 3.5 倍。本文将深入探讨两个核心话题:

  1. 如何组织高效的样式复用目录结构
  2. 如何利用 @theme 指令实现灵活的多主题切换

一、Tailwind CSS v4 核心变化回顾

1.1 CSS-first 配置

v4 最大的变革是从 JavaScript 配置转向 CSS 配置:

/* v3 旧方式 */
// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: '#3490dc'
      }
    }
  }
}

/* v4 新方式 */
/* app.css */
@theme {
  --color-primary: #3490dc;
}

1.2 Oxide 引擎性能提升

  • 构建速度快 3.5 倍
  • 🧠 内存使用减少 45%
  • 🔍 文件扫描速度大幅提升

1.3 破坏性变化

  • ❌ 移除 @tailwind base 指令
  • ❌ 移除 tailwind.config.js 支持(需迁移到 CSS 配置)
  • ⚠️ 现代浏览器成为硬性要求(不再支持 IE11)

二、样式复用目录结构最佳实践

2.1 组件复用方法论

Tailwind CSS 倡导 Utility-first 理念,但也支持通过 @layer components 创建可复用组件:

@layer components {
  .btn-primary {
    @apply px-4 py-2 bg-blue-600 text-white rounded-lg
           hover:bg-blue-700 transition-colors;
  }
}

2.2 推荐目录结构方案

方案一:按文件类型组织(适合中小型项目)
src/
├── styles/
│   ├── app.css              # 主入口文件
│   ├── theme.css            # 主题配置(@theme)
│   ├── components.css       # 可复用组件类
│   └── utilities.css        # 自定义工具类
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   └── Button.module.css  # 组件特定样式
│   └── Card/
│       ├── Card.tsx
│       └── Card.module.css

app.css 示例

@import './theme.css';
@import './components.css';
@import './utilities.css';

@layer theme;
@layer components;
@layer utilities;
方案二:按功能领域组织(适合大型项目)
src/
├── design-system/
│   ├── styles/
│   │   ├── foundation/          # 基础样式
│   │   │   ├── colors.css       # 颜色系统
│   │   │   ├── typography.css   # 字体系统
│   │   │   └── spacing.css      # 间距系统
│   │   ├── components/          # 可复用组件
│   │   │   ├── button.css
│   │   │   ├── card.css
│   │   │   └── input.css
│   │   └── utilities/           # 自定义工具类
│   └── tokens/
│       └── index.css            # 设计令牌
│
├── components/
│   ├── ui/                      # 基础UI组件
│   │   ├── Button/
│   │   └── Card/
│   └── features/                # 业务组件
│       └── UserProfile/
方案三:原子设计方法(适合设计系统)
src/
├── styles/
│   ├── atoms/           # 原子:按钮、输入框等基础元素
│   │   ├── button.css
│   │   ├── input.css
│   │   └── badge.css
│   ├── molecules/       # 分子:搜索框(输入+按钮)
│   │   ├── search.css
│   │   └── card.css
│   ├── organisms/       # 有机体:导航栏、页脚
│   │   ├── navbar.css
│   │   └── footer.css
│   ├── templates/       # 模板:页面布局
│   └── main.css         # 入口文件

2.3 @theme 与 @layer 最佳实践

定义设计令牌
@theme {
  /* 颜色系统 */
  --color-primary: #3490dc;
  --color-primary-dark: #2779bd;

  /* 间距系统 */
  --spacing-xs: 0.5rem;
  --spacing-sm: 1rem;
  --spacing-md: 1.5rem;
  --spacing-lg: 2rem;

  /* 字体 */
  --font-sans: 'Inter', system-ui, sans-serif;
  --text-h1: 2.5rem;
}
创建可复用组件
@layer components {
  .btn {
    @apply inline-flex items-center justify-center
         font-medium rounded-lg transition-colors;
  }

  .btn-sm { @apply btn px-3 py-1.5 text-sm; }
  .btn-md { @apply btn px-4 py-2 text-base; }
  .btn-lg { @apply btn px-6 py-3 text-lg; }

  .btn-primary {
    @apply btn bg-primary text-white hover:bg-primary-dark;
  }
}

三、@theme 指令深度解析

3.1 工作原理

@theme 将 CSS 自定义属性转换为 Tailwind 实用工具类:

@theme {
  --color-primary: #3490dc;
}

自动生成的工具类

  • .text-primary { color: var(--color-primary) }
  • .bg-primary { background-color: var(--color-primary) }
  • .border-primary { border-color: var(--color-primary) }

自动映射规则

  • --color-*text-*bg-*border-*
  • --font-*font-*
  • --spacing-*p-*m-*w-*h-*
  • --text-*text-*(字体大小)

3.2 完整的令牌类型

@theme {
  /* 1. 颜色 */
  --color-primary: #3490dc;
  --color-neutral-50: #f9fafb;

  /* 2. 字体 */
  --font-sans: 'Inter', system-ui, sans-serif;
  --font-mono: 'Fira Code', monospace;

  /* 3. 间距 */
  --spacing-xs: 0.5rem;
  --spacing-sm: 1rem;
  --spacing-md: 1.5rem;
  --spacing-lg: 2rem;

  /* 4. 字体大小 */
  --text-xs: 0.75rem;
  --text-sm: 0.875rem;
  --text-base: 1rem;
  --text-h1: 2.5rem;

  /* 5. 圆角 */
  --radius-sm: 0.125rem;
  --radius-md: 0.375rem;
  --radius-lg: 0.5rem;

  /* 6. 阴影 */
  --shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.1);

  /* 7. 断点 */
  --breakpoint-3xl: 1920px;

  /* 8. 行高 */
  --leading-tight: 1.25;
  --leading-normal: 1.5;
}

在 HTML 中使用

<div class="bg-primary text-white p-lg rounded-md shadow-card">
  <h1 class="text-h1 font-sans">标题</h1>
  <p class="text-base leading-normal">内容</p>
</div>

在 CSS 中使用

.custom-card {
  background-color: var(--color-primary);
  padding: var(--spacing-md);
}

四、两种主题实现方案对比

4.1 方案一:基于系统偏好的自动主题

@theme {
  /* Light 主题(默认) */
  --color-background: #ffffff;
  --color-foreground: #1f2937;
  --color-card: #f9fafb;
  --color-border: #e5e7eb;
  --color-primary: #3b82f6;
  --color-primary-foreground: #ffffff;
}

@media (prefers-color-scheme: dark) {
  @theme {
    --color-background: #111827;
    --color-foreground: #f9fafb;
    --color-card: #1f2937;
    --color-border: #374151;
    --color-primary: #60a5fa;
    --color-primary-foreground: #111827;
  }
}

特点

  • ✅ 无需 JavaScript
  • ✅ 自动跟随系统设置
  • ❌ 用户无法手动切换

4.2 方案二:手动切换主题(推荐)

结合系统偏好和手动切换的完整方案:

/* 定义 CSS 变量(HSL 格式更灵活) */
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --card: 0 0% 100%;
  --primary: 221.2 83.2% 53.3%;
  --radius: 0.5rem;
}

/* 系统偏好 Dark 主题 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --card: 222.2 84% 4.9%;
    --primary: 217.2 91.2% 59.8%;
  }
}

/* 手动 Dark 主题 */
:root[data-theme="dark"] {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  --card: 222.2 84% 4.9%;
  --primary: 217.2 91.2% 59.8%;
}

/* 注册到 Tailwind */
@theme {
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  --color-card: hsl(var(--card));
  --color-primary: hsl(var(--primary));
  --radius: var(--radius);
}

特点

  • ✅ 默认跟随系统
  • ✅ 支持手动切换
  • ✅ 用户偏好持久化
  • ✅ 平滑过渡动画

五、完整的主题切换实现

5.1 CSS 配置(theme.css)

/* styles/theme.css */

/* === 设计令牌(HSL 格式)=== */
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --card: 0 0% 100%;
  --card-foreground: 222.2 84% 4.9%;
  --popover: 0 0% 100%;
  --popover-foreground: 222.2 84% 4.9%;
  --primary: 221.2 83.2% 53.3%;
  --primary-foreground: 210 40% 98%;
  --secondary: 210 40% 96.1%;
  --secondary-foreground: 222.2 47.4% 11.2%;
  --muted: 210 40% 96.1%;
  --muted-foreground: 215.4 16.3% 46.9%;
  --accent: 210 40% 96.1%;
  --accent-foreground: 222.2 47.4% 11.2%;
  --destructive: 0 84.2% 60.2%;
  --destructive-foreground: 210 40% 98%;
  --border: 214.3 31.8% 91.4%;
  --input: 214.3 31.8% 91.4%;
  --ring: 221.2 83.2% 53.3%;
  --radius: 0.5rem;
}

/* === 系统偏好 Dark 主题 === */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --card: 222.2 84% 4.9%;
    --card-foreground: 210 40% 98%;
    --popover: 222.2 84% 4.9%;
    --popover-foreground: 210 40% 98%;
    --primary: 217.2 91.2% 59.8%;
    --primary-foreground: 222.2 47.4% 11.2%;
    --secondary: 217.2 32.6% 17.5%;
    --secondary-foreground: 210 40% 98%;
    --muted: 217.2 32.6% 17.5%;
    --muted-foreground: 215 20.2% 65.1%;
    --accent: 217.2 32.6% 17.5%;
    --accent-foreground: 210 40% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 40% 98%;
    --border: 217.2 32.6% 17.5%;
    --input: 217.2 32.6% 17.5%;
    --ring: 224.3 76.3% 94.1%;
  }
}

/* === 手动 Dark 主题覆盖 === */
:root[data-theme="dark"] {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  --card: 222.2 84% 4.9%;
  --card-foreground: 210 40% 98%;
  --popover: 222.2 84% 4.9%;
  --popover-foreground: 210 40% 98%;
  --primary: 217.2 91.2% 59.8%;
  --primary-foreground: 222.2 47.4% 11.2%;
  --secondary: 217.2 32.6% 17.5%;
  --secondary-foreground: 210 40% 98%;
  --muted: 217.2 32.6% 17.5%;
  --muted-foreground: 215 20.2% 65.1%;
  --accent: 217.2 32.6% 17.5%;
  --accent-foreground: 210 40% 98%;
  --destructive: 0 62.8% 30.6%;
  --destructive-foreground: 210 40% 98%;
  --border: 217.2 32.6% 17.5%;
  --input: 217.2 32.6% 17.5%;
  --ring: 224.3 76.3% 94.1%;
}

/* === 注册到 Tailwind v4 === */
@theme {
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  --color-card: hsl(var(--card));
  --color-card-foreground: hsl(var(--card-foreground));
  --color-popover: hsl(var(--popover));
  --color-popover-foreground: hsl(var(--popover-foreground));
  --color-primary: hsl(var(--primary));
  --color-primary-foreground: hsl(var(--primary-foreground));
  --color-secondary: hsl(var(--secondary));
  --color-secondary-foreground: hsl(var(--secondary-foreground));
  --color-muted: hsl(var(--muted));
  --color-muted-foreground: hsl(var(--muted-foreground));
  --color-accent: hsl(var(--accent));
  --color-accent-foreground: hsl(var(--accent-foreground));
  --color-destructive: hsl(var(--destructive));
  --color-destructive-foreground: hsl(var(--destructive-foreground));
  --color-border: hsl(var(--border));
  --color-input: hsl(var(--input));
  --color-ring: hsl(var(--ring));

  --radius: var(--radius);
}

5.2 TypeScript 类型定义

// src/types/theme.d.ts

export type Theme = 'light' | 'dark' | 'system'

declare global {
  interface Window {
    themeManager?: {
      setTheme(theme: Theme): void
      getTheme(): Theme
      reset(): void
    }
  }
}

5.3 React 主题 Hook

// src/hooks/useTheme.ts

import { useEffect, useState } from 'react'

export function useTheme() {
  const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system')
  const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light')

  useEffect(() => {
    // 初始化主题
    const savedTheme = (localStorage.getItem('theme') as Theme | null) || 'system'
    setTheme(savedTheme)
    applyTheme(savedTheme)

    // 监听系统主题变化
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
    const handleChange = () => {
      if (localStorage.getItem('theme') !== 'light' &&
          localStorage.getItem('theme') !== 'dark') {
        applyTheme('system')
      }
    }

    mediaQuery.addEventListener('change', handleChange)
    return () => mediaQuery.removeEventListener('change', handleChange)
  }, [])

  useEffect(() => {
    // 监听 resolved theme 变化
    const currentTheme = localStorage.getItem('theme') || 'system'
    if (currentTheme === 'system') {
      const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
      setResolvedTheme(isDark ? 'dark' : 'light')
    } else {
      setResolvedTheme(currentTheme as 'light' | 'dark')
    }
  }, [theme])

  const applyTheme = (newTheme: Theme) => {
    const root = document.documentElement

    if (newTheme === 'system') {
      root.removeAttribute('data-theme')
      localStorage.removeItem('theme')
      const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
      setResolvedTheme(isDark ? 'dark' : 'light')
    } else {
      root.setAttribute('data-theme', newTheme)
      localStorage.setItem('theme', newTheme)
      setResolvedTheme(newTheme)
    }

    setTheme(newTheme)
  }

  return { theme, resolvedTheme, setTheme: applyTheme }
}

5.4 主题切换组件

// src/components/ThemeToggle.tsx

import { useTheme } from '@/hooks/useTheme'
import { Moon, Sun, Monitor } from 'lucide-react'

export function ThemeToggle() {
  const { theme, setTheme } = useTheme()

  return (
    <div className="flex gap-2 bg-card p-1 rounded-lg border border-border">
      <button
        onClick={() => setTheme('light')}
        className={`p-2 rounded-md transition-colors ${
          theme === 'light'
            ? 'bg-primary text-primary-foreground'
            : 'text-muted-foreground hover:text-foreground'
        }`}
        aria-label="Light theme"
      >
        <Sun className="w-4 h-4" />
      </button>

      <button
        onClick={() => setTheme('dark')}
        className={`p-2 rounded-md transition-colors ${
          theme === 'dark'
            ? 'bg-primary text-primary-foreground'
            : 'text-muted-foreground hover:text-foreground'
        }`}
        aria-label="Dark theme"
      >
        <Moon className="w-4 h-4" />
      </button>

      <button
        onClick={() => setTheme('system')}
        className={`p-2 rounded-md transition-colors ${
          theme === 'system'
            ? 'bg-primary text-primary-foreground'
            : 'text-muted-foreground hover:text-foreground'
        }`}
        aria-label="System theme"
      >
        <Monitor className="w-4 h-4" />
      </button>
    </div>
  )
}

5.5 应用中使用示例

// src/app.tsx

import { useTheme } from '@/hooks/useTheme'
import { ThemeToggle } from '@/components/ThemeToggle'

function App() {
  const { resolvedTheme } = useTheme()

  return (
    <div className="min-h-screen bg-background text-foreground transition-colors duration-300">
      <header className="border-b border-border bg-card">
        <div className="container mx-auto px-4 py-4 flex justify-between items-center">
          <h1 className="text-xl font-bold">我的应用</h1>
          <ThemeToggle />
        </div>
      </header>

      <main className="container mx-auto px-4 py-8">
        <div className="max-w-2xl mx-auto space-y-6">
          <div className="bg-card p-6 rounded-lg border border-border shadow-sm">
            <h2 className="text-2xl font-semibold text-card-foreground mb-4">
              欢迎使用 Tailwind CSS v4
            </h2>
            <p className="text-muted-foreground mb-6">
              当前活动主题: <strong className="text-foreground">{resolvedTheme}</strong>
            </p>

            <div className="flex flex-wrap gap-3">
              <button className="bg-primary text-primary-foreground px-4 py-2 rounded-md">
                主要按钮
              </button>
              <button className="bg-secondary text-secondary-foreground px-4 py-2 rounded-md">
                次要按钮
              </button>
              <button className="bg-accent text-accent-foreground px-4 py-2 rounded-md">
                强调按钮
              </button>
              <button className="bg-destructive text-destructive-foreground px-4 py-2 rounded-md">
                危险按钮
              </button>
            </div>
          </div>
        </div>
      </main>
    </div>
  )
}

六、关键要点总结

6.1 @theme 最佳实践

  1. 使用 HSL 格式:便于调整和透明度控制
  2. 两层变量设计:中性变量 + 语义变量
  3. 合理的命名规范:使用 --color-*--spacing-* 等前缀
  4. 模块化拆分:按功能拆分 CSS 文件
  5. 避免过度抽象:只在真正需要复用时创建组件类

6.2 主题切换最佳实践

  1. 默认跟随系统:优先使用 prefers-color-scheme
  2. 手动覆盖选项:提供用户手动切换能力
  3. 本地存储:使用 localStorage 持久化用户偏好
  4. 平滑过渡:添加 transition-colors duration-300
  5. 无障碍支持:使用合适的 ARIA 标签

6.3 性能优化技巧

  • 使用 Oxide 引擎:Tailwind v4 默认开启
  • 📦 按需生成:只生成实际使用的类
  • 🔍 优化扫描:排除 node_modules 和构建产物
  • 💾 浏览器缓存:合理配置缓存策略

七、迁移建议

从 v3 迁移到 v4

  1. 备份现有配置:保留 tailwind.config.js 作为参考
  2. 逐步迁移:先将颜色配置迁移到 @theme
  3. 测试对比:确保生成相同的工具类
  4. 更新构建工具:使用官方 v4 插件
  5. 验证主题:测试主题切换功能

常见问题

Q:v4 还支持 tailwind.config.js 吗? A:不支持,必须使用 CSS-first 配置。

Q:如何实现动态主题? A:使用 CSS 变量 + :root[data-theme="dark"]

Q:性能提升明显吗? A:构建速度提升 3.5 倍,感知非常明显。


结语

Tailwind CSS v4 通过 @theme 指令和 CSS-first 配置,为样式复用和主题系统带来了前所未有的灵活性和性能。结合合理的目录结构,可以构建出易维护、高性能的现代 Web 应用。

核心优势

  • 🎯 CSS 原生方式定义主题
  • ⚡ 构建速度大幅提升
  • 🔧 更直观的配置方式
  • 🎨 灵活的主题切换

希望本文能帮助您更好地掌握 Tailwind CSS v4 的新特性,构建出更加优雅的前端项目!


参考资料


本文撰写于 2025 年 12 月,基于 Tailwind CSS v4.0+ 版本

【博文精读】Chrome CSS 2025年回顾

2025年12月30日 11:12

本文由体验技术团队申君健原创。

序言

近日发现 Chrome 官方技术平台 chrome.dev 发布了一篇极具价值的 CSS 技术总结文章,原文链接为:CSS-Wrapped-2025,Wrapped 单词在这里是打包,总结,回顾的意思。Chrome官方罗列了2025年的新增CSS和组件特性等,有需要的小伙伴可以关注一下。此外,Chrome 官方亦发布了 2024 年度的 CSS 特性回顾文章,链接为:CSS-Wrapped-2024,可以对照查阅。

《CSS Wrapped 2025》一文共分为三个核心章节,分别是可定制的组件(Customizable Components)、 下一代交互(Next-gen Interactions)、 优化的人体工程学(Optimized ergonomics)。 由于精力时间有限,本文先精读第一部分,有机会再分享后续部分。

2025 全年,Chrome发布的版本为 132~143,本文的特性也集中在这个范围,它有很多全新的概念,或者是去年CSS概念的一些延伸。前端人员总不能第一时间使用新特性,以兼容性为借口忽视新技术,我也是一样。所以借此文章中新特性为提纲,全面总结该特性的知识,补充我的一些理解和总结。同时每一个新特性还准备一句话的解释完整示例,方便大家快速了解。Chrome 143升级好了吗,开始带你飞!

一、命令调用器 (Invoker Commands)

一句话的解释

命令调用器是通过Button元素,向Dialog,popover元素或任意元素上触发一个动作命令。 完整示例

命令调用器特性兼具声明式语法的高可读性优势,且能有效减少 JavaScript 代码的编写量。在该特性中,Button 元素被定义为 “命令源”,接收命令执行的元素则为 “命令目标”。

命令源:⭐仅 Button 元素才允许当命令源,它添加以下属性:

  • 【attr】commandfor: 属性值为命令目标元素的 id,用于关联对应的命令目标。
  • 【attr】command: 用于指定点击 Button 元素后触发的命令动作,其属性值说明如下:
命令 行为目标 等效js 备注
show-modal 打开dialog dialog.showModal()
close 关闭dialog dialog.close()
request-close 请求关闭dialog dialog.requestClose() 可取消: ev.preventDefault()
show-popover 打开popover el.showPopover()
hide-popover 关闭popover el.hidePopover()
toggle-popover 切换popover el.togglePopover()
--any-command 自定义命令 - 事件名必须 -- 打头
目标上监听command事件
非冒泡,可取消的事件
  • 【prop】command: 同上
  • 【prop】commandForElement: 同 commandfor ,值为HTMLElement对象。

命令目标: 通常是 dialog, popover元素,为它们添加一个事件:

  • 【event】command: 触发在目标元素上的事件。 其中事件参数 event.command 是命令值。

兼容性

支持chrome135+  ff144+, polyfill 方案

总结

  1. 该特性的核心价值不仅在于减少 JavaScript 代码量,更在于实现了更好的可读性,更好的语义化,更好的AI识别。Dialog元素是存在较久的冷门标签,Popover API是近两年的新特性,之前操作他们必须通过Javascript代码。
  2. 该特性也是一个微型的通知系统,某些程度上可以代替 new CustomEvent的使用。同样的,更好的可读性,参见图片翻转的示例。
  3. 命令源只能是Button,某种程度上限制了它的使用。

二、对话框轻量关闭(Dialog Light Dismiss)

一句话的解释

继 Popover Api 引入 Light Dismiss 之后,Dialog 也支持了它 完整示例

Light Dismiss 直接翻译就是轻量关闭,友好关闭,具体是指通过点击 ::backdrop 区域、按下 Esc 键即可触发目标元素自动关闭的交互行为。Light Dismiss同样的具备声明式可阅读性,还能避免Javascript的使用。

<dialog closedby="none">  不触发关闭 </dialog>
<dialog closedby="closerequest">接受 esc或其它js触发 </dialog>
<dialog closedby="any">  接受任何触发 </dialog>

Light Dismiss 通常是用户在交互过程中预期的默认行为,这一设计不仅体现了 Chrome 对用户使用体验人体工程学的关注,更彰显了其对开发者开发体验人体工程学的重视。开发者无需进行额外开发,即可获得预期的合理结果。延伸了解它的一些细节:

  • closerequest 与 any 的相比,它不接受点击::backdrop区域关闭;此外移动端的手指侧滑或导航回退也会触发closerequest的行为。
  • dialog.requestClose()在dialog元素上触发 cancel 和 close事件, 而dialog.close() 只触发close事件。 在cancel事件中执行ev.preventDefault()可阻止关闭。
  • dialog元素没有open事件,但它有 toggle, beforetoggle事件, 用来监听打开关闭。 事件对象的oldState,newState用来判断切换的方向。 此外,只有dialog 和 弹出层支持这2个事件名。

兼容性

支持chrome134+  ff141+, polyfill 方案

延伸理解 Popover API 的Light Dismiss

<button popovertarget="mypopover" popovertargetaction="toggle">切换显示</button>
<div id="mypopover" popover>这是一个 auto 弹出层</div>

命令源:⭐仅 Button 元素和Input(type=button)才允许当命令源,它添加以下属性:

  • 【attr】popovertarget: 其值为popover元素id
  • 【attr】popovertargetaction: 点击的命令动作,其值为: 'hide' | 'show' | 'toggle'

触发popover还可以用传统的Javascript, 或者Button的commands 模式,比如: el.showPopover()

popover层: 任意添加了 [popover] 属性的元素

  • 【attr】popover: 设置元素为一个弹出层,它最早支持以下2个值:
    • auto: 自动模式,也是默认值。 auto即符合Light Dismiss默认关闭行为。同一个页面上,auto类别的元素只能显示一个。
    • manual: 手动模式。必须显示的声明popovertargetaction,或调用Javascript函数才触发,比如:el.showPopover()。同一个页面上,manual类别元素可显示多个。

参考完整示例,对比 Dialog 与 Popover API :

  • 都支持Invoker Commands 和 Light Dismiss
  • 都支持Javascript控制和声明式表达: dialog元素的closedby 和 popover元素的popover属性
  • 都会产生一个Top Layer, 无须z-index就能置顶元素,且不受父元素的position影响。
  • 命令源和命令目标之间会隐式的产生aria-details关联aria-expanded,用于触发焦点导航等。

Popover API的这些特性兼容性为:chrome114+, ff125+

三、增强Popover (popover="hint") 与 兴趣调用(Interest Invoker)

一句话的解释

hint暗示:一种更轻量的触发行为的popover类别 完整示例

在上小节中,已经讲了popover原有的2个类别,今年它又新增了一个类别:hint 暗示。这种hint弹出层,不仅可以用原来的方法触发它,还增加了一种兴趣调用触发。 兴趣调用Interest Invoker是指:通过非点击事件,比如hover,mouseover,mouseout, focus,blur 它的变化。悬浮就显示,离开就隐藏,十分符合tooltip组件场景。

  <button interestfor="mypopover1">悬浮触发 hint1 弹出层</button>
  <div id="mypopover1" popover='hint'>这是一个 hint1 弹出层</div>

命令源:它新增以下相关内容:

  • 【attr】interestfor: 属性值为 hint类别的popover元素id
  • 【css-rule】interest-delay: 设置悬浮触发和离开隐藏的时间。 它是复合属性: interest-delay-start, interest-delay-end。默认触发的时间是 0.5s。
  • 【css-selector】 :interest-source 和 :interest-target 是指,如果当前兴趣正在发生,那么触发源和hint 弹出层就分别为具有上面的伪类。类似于 dialog打开时,dialog:open的伪类一样。详见上面示例。

hint 弹出层:新增以下事件:

  • 【event】interest: 触发显示的InterestEvent事件, 事件的source指向触发源元素。
  • 【event】loseinterest: 离开失去的InterestEvent事件,事件的source指向触发源元素。

兴趣调用与前面2节的内容有一些重要的差异:

  1. 强调必须非点击事件,场景对应“悬而未决”的状态,可以配合popover="hint"使用。
  2. 触发源更广泛,不仅是Button元素,还允许 <a>, <button>,<area>,SVG <a>
  3. hint类别不影响auto类别的弹窗,不会主动触发auto弹窗关闭。

长期以来Web标准对hover行为是淡视的,只有title属性和 :hover的伪类,一直缺少关键的hover事件。此次提供 interest事件,借此可以变相的视为一种hover事件

兼容性

支持chrome 142+, 不支持:ff,safari, polyfill 方案

四、可自定义的select (Customizable select)

一句话的解释

增强的select 和 option 元素,丰富的伪类、伪元素,定制更容易 完整示例

ScreenShot_20251230103200.PNG

可定制的select增加了很多dom规范和伪类,伪元素,内容太多不宜展开细述,感兴趣看上面的完整MDN示例,我已经增加详细的注释。此处仅列出一些重要的概念和事项,以便能快速理解:

  1. base-select 设置:

select 和 ::picker(select)伪元素都必须添加规则: appearance: base-select ,以区别于传统select样式。

  1. 弹出层::picker(select)特性:

它渲染在页面顶层Top Layer,这意味着它会显示在所有其他内容之上,不会被父容器裁剪。浏览器还会根据视口中的可用空间自动调整下拉列表的位置和翻转。

  1. 增强的option:

传统的option元素仅支持 label,value属性和selected,disabled的布尔属性。option中嵌套有其它元素,都是会忽略的

增强后的option元素支持嵌套span,img等等普通元素,但要避免嵌套 a, input 等交互元素就行了。 

  1. 新增 selectedcontent 元素:

该元素必须遵循 select > button > selectedcontent 的嵌套结构,详见示例。

select的选择值(即change事件)之后,选中的option的节点会被cloneNode创建副本,插入到selectedcontent中,所以他们结构一样,但不是同一个元素实例。

同时button是惰性的,不响应focus等,行为更像是 div

option 和 selectedcontent 的子项,都可以用普通的 css 选择器去分别控制样式。

  1. select 借用 Popover API

隐式借用了非常多的Popover 特性,比如 :popover-open伪类,无需anchor-name的隐式的锚点引用,且可以定义弹出层与锚点的位置关系,溢出翻转等等。

  1. select的multiple 和 optgroup 未增强

这意味着多选和分组功能,需要重新实现,对于组件库的作者来说,这无疑得回退到传统方案,幸好有Popover API。

兼容性:

支持 chrome 135+ , ff,safari均 不支持😭,polyfill 方案

这个方案并非真正意义的polyfill, 它使用自定义的 webComponent技术实现了平替。

五、滚动控制伪元素(::scroll-marker/button())

一句话的解释

为滚动容器的添加伪元素,用于控制容器滚动 完整示例

HTML早早添加了dialog, detail 等元素,但一直没有增加一个轮播图元素,今年只抠抠搜搜添加了三个伪元素,或许是因为添加一个新元素需要考虑的事情太多。

  1. ::scroll-button()  滚动容器按钮的伪元素,点击它会触发容器滚动。它非常类似于 ::before, ::after作用, 都需要content才显示,且呈现在容器的内部。

每个容器最多有4个滚动方向,括号的作用是指定滚动方向,可取值:*, left,right,up,down, block-end,block-start, inline-end,inline-start等。

按钮伪元素具有状态,比如容器滚动到两端之后,滚动按钮会自动禁用。 它具有以下状态: enabled, disabled,hover,active,focus

  1. ::scroll-marker 是滚动容器中,指示滚动项的伪元素,它同样也需要content才显示。

它具有 :target-current 伪类, 表示滚动到当前滚动项。当然, :hover, :active等伪类也能使用

  1. ::scroll-marker-group 是呈现在滚动容器内部的伪元素,收集容纳所有的::scroll-marker元素。

marker-group元素自身没有高度,但可以设置边框,布局,间距等内容。

兼容性:

支持chrome 135+ , ff,safari均 不支持😭。由于它是css 特性,无法Polyfill, 建议使用传统的div去实现即可!

六、设置滚动标记组容器(scroll-target-group)

一句话的解释

设置元素为滚动容器 完整示例

CSS属性 scroll-target-group 用来指定一个元素为滚动标记组容器, 它只有2个值: 

  • none: 元素非滚动标记组容器
  • auto: 元素为滚动标记组容器

滚动标记组容器中通常包含锚点链接列表等,配合伪类 :target-current 来突出显示某个锚点,效果非常类似传统的Anchor组件,当容器滚动时,可以高亮指定的目录项,不过这些都是浏览器自动完成的,不需要一行javascript。

它与::scroll-marker-group 有某些相似点:

  • ::scroll-marker-group:是在某个元素内部,创建一个伪元素容器,用来容纳::sroll-marker, 都是伪元素。
  • scroll-target-group: 是把一个真实元素变为滚动容器,内部放真实的link 类元素,所以控制上会更灵活。

从官方的态度看,这2个概念极其相近,都是定义了一个滚动容器,且内部的锚点行为一致,均支持伪类 :target-current 代表高亮状态,避免Javascript去滚动和设置高亮等。

兼容性:

支持 chrome 140+ ,但ff,safari均 不支持😭,且css 特性无法Polyfill。

七、锚定容器查询(Anchored Container Queries)

一句话的解释

锚点定位时,翻转状态可以查询完整示例

2024年的CSS回顾中,介绍了CSS锚点定位-—— anchor positioning, 实现类似 Tooltip组件的效果,让一个弹出层锚定到目标元素周围,且能自动翻转到适合位置,避免使用Javascript。 它的兼容性: chrome125+, safari26+, ff 不支持。

下面例子演示了:CSS锚点定位。tooltip会锚定在button的上下, 当滚动到边界时,会自动翻转显示。

.my-button {
  anchor-name: --my-btn-anchor; /* 定义一个名为 --my-btn-anchor 的锚点 */
}

.my-tooltip {
  position: absolute; /* 或 fixed */
  position-anchor: --my-btn-anchor; /* 关联到上面定义的锚点 */
  position-area: bottom;
  position-try-fallbacks: flip-block; 
}

思考一个问题:如果my-tooltip元素有小三角指示方向,那么简单的翻转后,小三角的位置怎么旋转呢? 答案是:定位元素无法意识(be aware)状态,小三角方向会错误。

此问题的解决方案即本次新增了CSS锚定容器查询能力,它通过指定tooltip的 container-type: anchored, 然后使用@container anchored 的查询语法,就让tooltip查询到,意识到自身的翻转状态(fallbacks 状态)。

.tooltip {
  container-type: anchored;

  /* 默认在下方, 小三角向上,位置在底部 */
  &::before {
    content: '▲';
    position: absolute;
    bottom: 100%;  
  }
}
/** 当容器查询到锚点变化 */
@container anchored(fallback: flip-block) {
  .tooltip::before {
    /* 小三角向下,并移到到顶部 */
    content: '▼';
    bottom: auto;
    top: 100%;  
  }
}

要讲明白锚点定位需要很大篇幅,且该示例复杂,大家可以转到官网查看示例。 目前 container-type: anchored 的文档连MDN上都没有,是比较新的概念。

container-type有效值:

  • normal: 元素不支持任何查询
  • size: 支持 inline 和 block 元素的尺寸的高度和宽度查询
  • inline-size: 仅支持 inline 元素的尺寸的宽度查询
  • scroll-state: 支持滚动态的偏移量和是否滚动到底等查询,chrome 133+, 但ff,safari均不支持
  • anchored: 锚点查询

兼容性:

container-type: anchored 支持 chrome 143+ ,但ff,safari均不支持😭,且css 特性无法Polyfill。

总结

通过以上种种新特性,可以看出chrome 不仅关注使用用户体验,更关注开发者体验。通过增加类似command属性,或者Light Dismiss的默认行为,以及滚动容器,滚动容器伪元素等技巧,让许多场景都可以无Javascript实现了。 不仅大大减少开发代码,还有极强的DOM可读性。

我目前从事于组件库开发。在组件库的开发时,所采用的技术通常是落后于浏览器最新技术的,理由就是为了兼容用户浏览器。比如 dialog 元素已经是广泛兼容,但目前仍没有见到哪个组件库使用它。通过这次梳理技术,感觉借助 dialog 以及 popover api 可以极大简化以往的组件开发,诸如:监听按键,计算z-index,计算弹出层位置,监听滚动进行位置跟随等等,这些是问题bug集中爆发区,现在基本都可以无Js代码的实现了。

另外,前面的诸多未广泛兼容的技术,大都有相应的Polyfill,尤其是属性,函数和事件的Polyfill基本都能找到。CSS的新伪类,伪元素虽然很难有Polyfill,但可以用添加类名的方案来兼容,辅助以一些Js事件就可以实现某种程度上的polyfill。oddbird.tech是一家服务公司,得到过Google的赞助,它们一直关注开发Popover APIAnchor positioning的兼容方案。这些方案都让我们以及早的使用新技术进行开发。

如果只需要支持最新的浏览器,前端的春天来了!

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

❌
❌