在扁平化设计占据主流的当下,拟物化设计凭借更贴近现实的视觉质感和沉浸式的交互体验,正以新的姿态回归视野。本文将带大家从零实现一个兼具视觉美感、交互体验和无障碍特性的拟物化主题切换按钮 —— 点击切换亮色 / 暗色主题,太阳平滑变月亮,云朵隐去、星星浮现,全程伴随自然的过渡动画。

效果预览

  • 亮色主题:按钮背景为清新的浅蓝调,显示太阳和云朵元素,太阳位于左侧,光影层次丰富;

  • 暗色主题:按钮背景切换为深邃的暗色调,太阳平滑位移并变形为月亮(带陨石坑质感),云朵下沉隐藏,星星上浮显示;

  • 交互细节:鼠标悬停时太阳 / 月亮有轻微位移反馈,所有状态切换均有 0.3s 平滑过渡,主题状态持久化存储,且完美兼容屏幕阅读器。

核心设计思路

本次实现围绕「拟物化质感」「流畅交互」「无障碍适配」三大核心展开:

  • 拟物化视觉:通过多层阴影、内层阴影、光晕、圆角等 CSS 特性模拟现实物体的光影和质感;

  • 流畅交互:基于 CSS transition 实现状态切换动画,结合 JS 控制动画生命周期;

  • 无障碍设计:使用 ARIA 属性标记开关角色、状态,同步更新屏幕阅读器标签;

  • 持久化体验:通过 localStorage 保存主题偏好,页面刷新后自动恢复。

代码拆解与实现

一、HTML 结构:语义化 + 无障碍

HTML 结构遵循「分层设计」原则,同时兼顾语义化和无障碍属性:

<button class="theme-toggle" id="theme-toggle-btn" role="switch" aria-checked="false" aria-label="切换到暗色主题">
  <!-- 核心视觉容器:承载所有动态元素 -->
  <div class="theme-toggle__container">
    <!-- 云朵:亮色显示,暗色隐藏 -->
    <div class="theme-toggle__clouds"></div>
    <!-- 星星:暗色显示,亮色隐藏 -->
    <div class="theme-toggle__stars">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 55" fill="none">
        <path fill-rule="evenodd" clip-rule="evenodd" d="..." fill="currentColor"></path>
      </svg>
    </div>
    <!-- 太阳/月亮核心元素:通过遮罩实现形态切换 -->
    <div class="theme-toggle__sun">
      <div class="theme-toggle__moon-mask">
        <div class="theme-toggle__crater"></div>
        <div class="theme-toggle__crater"></div>
        <div class="theme-toggle__crater"></div>
      </div>
    </div>
  </div>
</button>

关键无障碍属性说明:

  • role="switch":标识按钮为「开关控件」,屏幕阅读器可识别;

  • aria-checked:标记开关状态(false = 亮色,true = 暗色);

  • aria-label:为屏幕阅读器提供操作描述,切换主题时同步更新。

二、CSS 实现:拟物化质感 + 状态切换

CSS 是拟物化效果的核心,我们通过「CSS 变量」「多层阴影」「过渡动画」实现视觉效果和状态切换。

全局变量定义:统一管理主题和尺寸

:root {
  --bg-color: #f0f0f0; /* 亮色背景 */
  --transition-standard: 0.3s ease; /* 通用过渡动画 */
  /* 按钮尺寸、颜色变量 */
  --base-scale: 120px; 
  --toggle-width: 5.625em;
  --toggle-height: 2.5em;
  /* 主题色变量 */
  --bg-toggle-light: #3d7eae;
  --bg-toggle-dark: #1d1f2c;
  --color-sun: #ecca2f;
  --color-moon: #c4c9d1;
  /* 阴影/光晕变量 */
  --shadow-primary: rgba(0, 0, 0, 0.25);
  --halo-color: rgba(255, 255, 255, 0.1);
}

/* 暗色主题覆盖变量 */
html.dark {
  --bg-color: #000;
}

拟物化质感:阴影与光影的魔法

以太阳元素为例,通过多层阴影模拟光影、光晕和内部质感:

.theme-toggle__sun {
  width: var(--sun-diameter);
  height: var(--sun-diameter);
  background-color: var(--color-sun);
  border-radius: 50%;
  /* 多层阴影:外层阴影(投影)+ 内层阴影(质感)+ 光晕(氛围) */
  box-shadow: 
    0.05em 0.125em 0.125em var(--shadow-primary),
    0em 0.05em 0.125em var(--shadow-primary),
    0.05em 0.05em 0.05em 0em rgba(254, 255, 239, 0.61) inset, /* 内层高光 */
    0em -0.05em 0.05em 0em #a1872a inset, /* 内层暗部 */
    0 0 0 0.625em var(--halo-color), /* 第一层光晕 */
    0 0 0 1.25em var(--halo-color), /* 第二层光晕 */
    0 0 0 1.875em var(--halo-color); /* 第三层光晕 */
  transition: transform var(--transition-standard);
}

状态切换:通过html.dark类控制元素显隐 / 位移

/* 暗色主题:太阳位移到右侧,月亮遮罩显示 */
html.dark .theme-toggle__sun {
  transform: translateX(calc(var(--toggle-width) - var(--sun-diameter) - var(--sun-offset)));
}
html.dark .theme-toggle__moon-mask {
  transform: translateX(0); /* 从右侧滑入覆盖太阳 */
}

/* 暗色主题:云朵下沉隐藏,星星上浮显示 */
html.dark .theme-toggle__clouds {
  transform: translateY(3em);
}
html.dark .theme-toggle__stars {
  transform: translateY(0.5em);
}

交互反馈:悬停动效

/* 亮色主题:鼠标悬停太阳轻微右移 */
.theme-toggle__container:hover .theme-toggle__sun {
  transform: translateX(calc(var(--sun-offset) + 0.187em));
}
/* 暗色主题:鼠标悬停月亮轻微左移 */
html.dark .theme-toggle__container:hover .theme-toggle__sun {
  transform: translateX(calc(var(--toggle-width) - var(--sun-diameter) - var(--sun-offset) - 0.187em));
}

三、JS 逻辑:交互控制 + 状态持久化

JS 负责处理点击事件、同步状态、持久化存储,同时保障无障碍体验。

核心函数:更新主题状态

function updateTheme(isDarkMode) {
  // 切换根元素dark类(控制CSS状态)
  docElement.classList.toggle("dark", isDarkMode);
  // 更新无障碍属性
  themeToggleButton.setAttribute("aria-checked", isDarkMode);
  themeToggleButton.setAttribute("aria-label", isDarkMode ? "切换到亮色主题" : "切换到暗色主题");
  // 持久化存储(异常处理:兼容隐私模式)
  try {
    localStorage.setItem("app-theme", isDarkMode ? "dark" : "light");
  } catch (e) {
    console.warn("本地存储不可用,主题无法持久化", e);
  }
}

事件绑定与初始化

// 点击事件:切换主题
themeToggleButton.addEventListener("click", () => {
  docElement.classList.add("is-animating"); // 标记动画状态
  const isDarkMode = docElement.classList.contains("dark");
  updateTheme(!isDarkMode);
});

// 动画结束:清理动画标记
themeContainer.addEventListener("transitionend", () => {
  docElement.classList.remove("is-animating");
});

// 页面初始化:恢复本地存储的主题
function initializeTheme() {
  const isDarkMode = docElement.classList.contains("dark");
  updateTheme(isDarkMode);
}
initializeTheme();

关键技术亮点

CSS 变量的灵活运用

通过全局 CSS 变量统一管理尺寸、颜色、过渡动画,修改主题时只需覆盖变量,无需改动大量样式,提升可维护性。

拟物化设计的落地技巧

  • 多层阴影(外层投影 + 内层阴影)模拟物体的光影和立体感;

  • 圆角 + 溢出隐藏实现太阳→月亮的形态切换;

  • box-shadow 拼接实现云朵的复杂形状(无需额外图片)。

无障碍设计的细节

  • 正确使用 ARIA 角色和状态属性;

  • 同步更新屏幕阅读器标签;

  • 按钮无默认样式但保留原生可点击特性。

鲁棒性保障

  • 本地存储添加异常捕获(兼容隐私模式 / 存储禁用场景);

  • 动画结束清理状态,避免样式污染;

  • 页面初始化同步状态,确保刷新后主题一致。

拓展与优化方向

  • 响应式适配:通过媒体查询调整--base-scale变量,适配不同屏幕尺寸;

  • 多主题扩展:新增更多主题(如黄昏、极简模式),只需扩展 CSS 变量和切换逻辑;

  • 性能优化:使用will-change提前声明动画属性,提升动画流畅度;

  • 手势支持:添加触摸滑动切换,适配移动端交互。

完整代码

HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>拟物化切换按钮</title>
	<link rel="stylesheet" href="css/Skeuomorphic-Toggle-Button.css">
</head>
<body>
<!-- 主题切换按钮:
     - class="theme-toggle":按钮基础样式类
     - id="theme-toggle-btn":JS获取该按钮的唯一标识
     - role="switch":ARIA角色,标识这是一个开关控件,提升可访问性
     - aria-checked="false":初始状态为未选中(亮色主题),可访问性属性
     - aria-label="切换到暗色主题":按钮的无障碍标签,屏幕阅读器会读取 -->
<button class="theme-toggle" id="theme-toggle-btn" role="switch" aria-checked="false" aria-label="切换到暗色主题">
  <!-- 按钮内部容器:承载太阳/月亮/星星/云朵等视觉元素 -->
  <div class="theme-toggle__container">
    <!-- 云朵元素:亮色主题显示,暗色主题隐藏 -->
    <div class="theme-toggle__clouds"></div>
    <!-- 星星元素:暗色主题显示,亮色主题隐藏 -->
    <div class="theme-toggle__stars">
      <!-- SVG星星图形:
           - xmlns="http://www.w3.org/2000/svg":SVG命名空间
           - viewBox="0 0 144 55":SVG视口大小,控制图形显示范围
           - fill="none":初始填充为空,继承父元素颜色 -->
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 55" fill="none">
        <!-- 星星路径:
             - fill-rule="evenodd" / clip-rule="evenodd":填充/裁剪规则为奇偶规则
             - d="...":星星的路径坐标,绘制多个星星形状
             - fill="currentColor":填充色继承父元素的color属性 -->
        <path fill-rule="evenodd" clip-rule="evenodd" d="M135.831 3.00688C135.055 3.85027 134.111 4.29946 133 4.35447C134.111 4.40947 135.055 4.85867 135.831 5.71123C136.607 6.55462 136.996 7.56303 136.996 8.72727C136.996 7.95722 137.172 7.25134 137.525 6.59129C137.886 5.93124 138.372 5.39954 138.98 5.00535C139.598 4.60199 140.268 4.39114 141 4.35447C139.88 4.2903 138.936 3.85027 138.16 3.00688C137.384 2.16348 136.996 1.16425 136.996 0C136.996 1.16425 136.607 2.16348 135.831 3.00688ZM31 23.3545C32.1114 23.2995 33.0551 22.8503 33.8313 22.0069C34.6075 21.1635 34.9956 20.1642 34.9956 19C34.9956 20.1642 35.3837 21.1635 36.1599 22.0069C36.9361 22.8503 37.8798 23.2903 39 23.3545C38.2679 23.3911 37.5976 23.602 36.9802 24.0053C36.3716 24.3995 35.8864 24.9312 35.5248 25.5913C35.172 26.2513 34.9956 26.9572 34.9956 27.7273C34.9956 26.563 34.6075 25.5546 33.8313 24.7112C33.0551 23.8587 32.1114 23.4095 31 23.3545ZM0 36.3545C1.11136 36.2995 2.05513 35.8503 2.83131 35.0069C3.6075 34.1635 3.99559 33.1642 3.99559 32C3.99559 33.1642 4.38368 34.1635 5.15987 35.0069C5.93605 35.8503 6.87982 36.2903 8 36.3545C7.26792 36.3911 6.59757 36.602 5.98015 37.0053C5.37155 37.3995 4.88644 37.9312 4.52481 38.5913C4.172 39.2513 3.99559 39.9572 3.99559 40.7273C3.99559 39.563 3.6075 38.5546 2.83131 37.7112C2.05513 36.8587 1.11136 36.4095 0 36.3545ZM56.8313 24.0069C56.0551 24.8503 55.1114 25.2995 54 25.3545C55.1114 25.4095 56.0551 25.8587 56.8313 26.7112C57.6075 27.5546 57.9956 28.563 57.9956 29.7273C57.9956 28.9572 58.172 28.2513 58.5248 27.5913C58.8864 26.9312 59.3716 26.3995 59.9802 26.0053C60.5976 25.602 61.2679 25.3911 62 25.3545C60.8798 25.2903 59.9361 24.8503 59.1599 24.0069C58.3837 23.1635 57.9956 22.1642 57.9956 21C57.9956 22.1642 57.6075 23.1635 56.8313 24.0069ZM81 25.3545C82.1114 25.2995 83.0551 24.8503 83.8313 24.0069C84.6075 23.1635 84.9956 22.1642 84.9956 21C84.9956 22.1642 85.3837 23.1635 86.1599 24.0069C86.9361 24.8503 87.8798 25.2903 89 25.3545C88.2679 25.3911 87.5976 25.602 86.9802 26.0053C86.3716 26.3995 85.8864 26.9312 85.5248 27.5913C85.172 28.2513 84.9956 28.9572 84.9956 29.7273C84.9956 28.563 84.6075 27.5546 83.8313 26.7112C83.0551 25.8587 82.1114 25.4095 81 25.3545ZM136 36.3545C137.111 36.2995 138.055 35.8503 138.831 35.0069C139.607 34.1635 139.996 33.1642 139.996 32C139.996 33.1642 140.384 34.1635 141.16 35.0069C141.936 35.8503 142.88 36.2903 144 36.3545C143.268 36.3911 142.598 36.602 141.98 37.0053C141.372 37.3995 140.886 37.9312 140.525 38.5913C140.172 39.2513 139.996 39.9572 139.996 40.7273C139.996 39.563 139.607 38.5546 138.831 37.7112C138.055 36.8587 137.111 36.4095 136 36.3545ZM101.831 49.0069C101.055 49.8503 100.111 50.2995 99 50.3545C100.111 50.4095 101.055 50.8587 101.831 51.7112C102.607 52.5546 102.996 53.563 102.996 54.7273C102.996 53.9572 103.172 53.2513 103.525 52.5913C103.886 51.9312 104.372 51.3995 104.98 51.0053C105.598 50.602 106.268 50.3911 107 50.3545C105.88 50.2903 104.936 49.8503 104.16 49.0069C103.384 48.1635 102.996 47.1642 102.996 46C102.996 47.1642 102.607 48.1635 101.831 49.0069Z" fill="currentColor"></path>
      </svg>
    </div>
    <!-- 太阳元素:亮色主题显示,暗色主题切换为月亮样式 -->
    <div class="theme-toggle__sun">
      <!-- 月亮遮罩:覆盖太阳,暗色主题显示为月亮 -->
      <div class="theme-toggle__moon-mask">
        <!-- 陨石坑元素1:月亮上的装饰 -->
        <div class="theme-toggle__crater"></div>
        <!-- 陨石坑元素2:月亮上的装饰 -->
        <div class="theme-toggle__crater"></div>
        <!-- 陨石坑元素3:月亮上的装饰 -->
        <div class="theme-toggle__crater"></div>
      </div>
    </div>
  </div>
</button>
<script src="js/Skeuomorphic-Toggle-Button.js"></script>
</body>
</html>

CSS

/* 通用选择器:重置所有元素和伪元素的盒模型、内外边距 */
*,
*::before,
*::after {
  box-sizing: border-box; /* 盒模型:宽高包含边框和内边距 */
  margin: 0; /* 清除默认外边距 */
  padding: 0; /* 清除默认内边距 */
}

/* 根元素样式:定义全局CSS变量,亮色主题默认值 */
:root {
  --bg-color: #f0f0f0; /* 页面背景色(亮色) */
  --transition-standard: 0.3s ease; /* 通用过渡动画:时长0.3秒,缓动曲线 */
}

/* 暗色主题下的根元素:覆盖背景色变量 */
html.dark {
  --bg-color: #000; /* 页面背景色(暗色) */
}

/* 页面主体样式 */
body {
  display: grid; /* 网格布局:方便居中 */
  place-content: center; /* 水平+垂直居中内容 */
  height: 100vh; /* 高度占满视口 */
  background-color: var(--bg-color); /* 背景色使用全局变量 */
  transition: background-color var(--transition-standard); /* 背景色过渡动画 */
}

/* 主题切换按钮:定义按钮相关的CSS变量 */
.theme-toggle {
  --base-scale: 120px; /* 基础缩放比例(控制按钮整体大小) */
  --toggle-width: 5.625em; /* 切换容器宽度(em基于base-scale) */
  --toggle-height: 2.5em; /* 切换容器高度 */
  --radius-pill: 100em; /* 胶囊状圆角(超大值实现半圆) */
  --sun-diameter: 2.125em; /* 太阳/月亮的直径 */
  --sun-offset: calc((var(--toggle-height) - var(--sun-diameter)) / 2); /* 太阳/月亮的上下偏移(垂直居中) */

  /* 亮色主题-切换容器背景 */
  --bg-toggle-light: #3d7eae;
  /* 暗色主题-切换容器背景 */
  --bg-toggle-dark: #1d1f2c;
  /* 太阳颜色 */
  --color-sun: #ecca2f;
  /* 月亮颜色 */
  --color-moon: #c4c9d1;
  /* 陨石坑颜色 */
  --color-crater: #959db1;
  /* 星星颜色 */
  --color-star: #fff;
  /* 云朵前景色 */
  --color-cloud-front: #f3fdff;
  /* 云朵后景色 */
  --color-cloud-back: #aacadf;

  /* 主阴影(深色) */
  --shadow-primary: rgba(0, 0, 0, 0.25);
  /* 高光阴影(浅色) */
  --shadow-highlight: rgba(255, 255, 255, 0.94);
  /* 光晕颜色 */
  --halo-color: rgba(255, 255, 255, 0.1);
}

/* 主题切换按钮基础样式 */
.theme-toggle {
  font-size: var(--base-scale); /* 字体大小控制按钮整体缩放 */
  background: none; /* 清除默认背景 */
  border: none; /* 清除默认边框 */
}

/* 切换按钮容器:核心视觉容器 */
.theme-toggle__container {
  width: var(--toggle-width); /* 宽度 */
  height: var(--toggle-height); /* 高度 */
  background-color: var(--bg-toggle-light); /* 初始背景(亮色) */
  border-radius: var(--radius-pill); /* 胶囊圆角 */
  cursor: pointer; /* 鼠标悬停显示手型 */

  position: relative; /* 子元素绝对定位的参考 */

  transition: background-color var(--transition-standard); /* 背景色过渡动画 */

  overflow: hidden; /* 隐藏溢出内容(如星星/云朵的位移) */

  box-shadow: 0.06em 0.06em 0.125em var(--shadow-highlight); /* 高光阴影 */
}

/* 暗色主题下的切换容器:替换背景色 */
html.dark .theme-toggle__container {
  background-color: var(--bg-toggle-dark);
}

/* 切换容器伪元素:添加内层阴影(质感) */
.theme-toggle__container::after {
  content: ""; /* 伪元素必须有content */
  position: absolute; /* 绝对定位 */
  inset: 0; /* 上下左右都为0,覆盖整个容器 */
  box-shadow: 0em 0.05em 0.187em rgba(0, 0, 0, 0.5) inset; /* 内层阴影 */
  border-radius: var(--radius-pill); /* 继承胶囊圆角 */
  pointer-events: none; /* 不拦截鼠标事件 */
}

/* 太阳元素样式 */
.theme-toggle__sun {
  width: var(--sun-diameter); /* 宽度=直径 */
  height: var(--sun-diameter); /* 高度=直径 */
  background-color: var(--color-sun); /* 太阳颜色 */
  border-radius: 50%; /* 圆形 */

  position: absolute; /* 绝对定位 */
  top: var(--sun-offset); /* 垂直居中 */
  transform: translateX(var(--sun-offset)); /* 初始水平位置(左侧) */

  /* 多层阴影:营造太阳的光影和光晕效果 */
  box-shadow: 0.05em 0.125em 0.125em var(--shadow-primary),
    0em 0.05em 0.125em var(--shadow-primary),
    0.05em 0.05em 0.05em 0em rgba(254, 255, 239, 0.61) inset,
    0em -0.05em 0.05em 0em #a1872a inset, 0 0 0 0.625em var(--halo-color),
    0 0 0 1.25em var(--halo-color), 0 0 0 1.875em var(--halo-color);

  transition: transform var(--transition-standard); /* 位移过渡动画 */

  overflow: hidden; /* 隐藏月亮遮罩的溢出部分 */
}

/* 暗色主题下的太阳:位移到右侧(变成月亮) */
html.dark .theme-toggle__container .theme-toggle__sun {
  transform: translateX(
    calc(var(--toggle-width) - var(--sun-diameter) - var(--sun-offset))
  );
}

/* 月亮遮罩:覆盖太阳,实现太阳→月亮的视觉切换 */
.theme-toggle__moon-mask {
  position: relative; /* 陨石坑绝对定位的参考 */
  width: 100%; /* 宽度100%(覆盖整个太阳) */
  height: 100%; /* 高度100% */
  background-color: var(--color-moon); /* 月亮颜色 */
  border-radius: inherit; /* 继承太阳的圆形圆角 */
  transform: translateX(100%); /* 初始位移到右侧(隐藏) */
  transition: transform var(--transition-standard); /* 位移过渡动画 */

  /* 内层阴影:营造月亮的质感 */
  box-shadow: 0.062em 0.062em 0.062em 0em rgba(254, 255, 239, 0.61) inset,
    0em -0.062em 0.062em 0em #969696 inset;
}

/* 暗色主题下的月亮遮罩:位移到0(显示,覆盖太阳) */
html.dark .theme-toggle__container .theme-toggle__moon-mask {
  transform: translateX(0);
}

/* 陨石坑基础样式:月亮上的装饰 */
.theme-toggle__crater {
  position: absolute; /* 绝对定位 */
  background-color: var(--color-crater); /* 陨石坑颜色 */
  border-radius: 50%; /* 圆形 */
  box-shadow: 0em 0.03em 0.06em var(--shadow-primary) inset; /* 内层阴影(凹陷感) */
}

/* 第一个陨石坑:位置和大小 */
.theme-toggle__crater:nth-of-type(1) {
  top: 0.75em;
  left: 0.3em;
  width: 0.75em;
  height: 0.75em;
}

/* 第二个陨石坑:位置和大小 */
.theme-toggle__crater:nth-of-type(2) {
  top: 0.9em;
  left: 1.375em;
  width: 0.375em;
  height: 0.375em;
}

/* 第三个陨石坑:位置和大小 */
.theme-toggle__crater:nth-of-type(3) {
  top: 0.3em;
  left: 0.8em;
  width: 0.25em;
  height: 0.25em;
}

/* 星星元素样式 */
.theme-toggle__stars {
  position: absolute; /* 绝对定位 */
  left: 0.3em; /* 左侧偏移 */
  transform: translateY(-2em); /* 初始位移到上方(隐藏) */
  transition: transform var(--transition-standard); /* 位移过渡动画 */
  width: 2.75em; /* 宽度 */
  color: var(--color-star); /* 星星颜色(继承给SVG) */
}

/* 暗色主题下的星星:位移到可见位置 */
html.dark .theme-toggle__container .theme-toggle__stars {
  transform: translateY(0.5em);
}

/* 云朵元素样式:通过box-shadow实现多段云朵形状 */
.theme-toggle__clouds {
  position: absolute; /* 绝对定位 */
  left: 0.3em; /* 左侧偏移 */
  top: 2em; /* 顶部偏移 */
  transform: translateY(0); /* 初始位置(可见) */
  width: 1.25em; /* 基础宽度 */
  height: 1.25em; /* 基础高度 */
  background-color: var(--color-cloud-front); /* 云朵前景色 */
  border-radius: 50%; /* 圆形基础形状 */

  /* 多层box-shadow:拼接出云朵的完整形状(前景+后景) */
  box-shadow: 0.937em 0.312em var(--color-cloud-front),
    1.437em 0.375em var(--color-cloud-front), 2.187em 0 var(--color-cloud-front),
    2.937em 0.312em var(--color-cloud-front),
    3.625em -0.062em var(--color-cloud-front),
    4.5em -0.312em var(--color-cloud-front),
    4.625em -1.75em 0 0.437em var(--color-cloud-front),
    -0.312em -0.312em var(--color-cloud-back),
    0.5em -0.125em var(--color-cloud-back),
    1.25em -0.062em var(--color-cloud-back),
    2em -0.312em var(--color-cloud-back), 2.625em 0em var(--color-cloud-back),
    3.375em -0.437em var(--color-cloud-back),
    4em -0.625em var(--color-cloud-back),
    4.125em -2.125em 0 0.437em var(--color-cloud-back);

  transition: transform var(--transition-standard); /* 位移过渡动画 */
}

/* 暗色主题下的云朵:位移到下方(隐藏) */
html.dark .theme-toggle__container .theme-toggle__clouds {
  transform: translateY(3em);
}

/* 鼠标悬停时的太阳:亮色主题下轻微右移(交互反馈) */
.theme-toggle__container:hover .theme-toggle__sun {
  transform: translateX(calc(var(--sun-offset) + 0.187em));
}

/* 鼠标悬停时的月亮:暗色主题下轻微左移(交互反馈) */
html.dark .theme-toggle__container:hover .theme-toggle__sun {
  transform: translateX(
    calc(
      var(--toggle-width) - var(--sun-diameter) - var(--sun-offset) - 0.187em
    )
  );
}

JS

// 脚本放在body之前,确保在渲染body之前就添加对应主题类(该段代码被注释,备用)
// (function () {
//   try {
//     const theme = localStorage.getItem("app-theme"); // 从本地存储获取主题
//     if (theme === "dark") { // 如果是暗色主题
//       document.documentElement.classList.add("dark"); // 根元素添加dark类
//     }
//   } catch (e) { // 捕获异常(如本地存储不可用)
//     console.warn("Could not access localStorage for theme setting.", e); // 打印警告
//   }
// })();

// 将脚本放在 </body> 之前, 确保所有 DOM 元素已加载(执行时机说明)

// 获取主题切换按钮DOM元素(通过ID)
const themeToggleButton = document.getElementById("theme-toggle-btn");
// 获取HTML根元素(用于切换dark类)
const docElement = document.documentElement;

/**
 * 更新主题样式和状态
 * @param {boolean} isDarkMode - 是否为暗色主题
 */
function updateTheme(isDarkMode) {
  // 切换根元素的dark类:isDarkMode为true则添加,false则移除
  docElement.classList.toggle("dark", isDarkMode);
  // 设置按钮的aria-checked属性(可访问性:标识开关状态)
  themeToggleButton.setAttribute("aria-checked", isDarkMode);
  // 根据主题切换按钮的无障碍标签文本
  const newLabel = isDarkMode ? "切换到亮色主题" : "切换到暗色主题";
  // 设置按钮的aria-label属性(屏幕阅读器读取)
  themeToggleButton.setAttribute("aria-label", newLabel);
  try {
    // 将当前主题保存到本地存储(持久化)
    localStorage.setItem("app-theme", isDarkMode ? "dark" : "light");
  } catch (e) {
    // 捕获本地存储异常(如隐私模式下不可用)
    console.warn("Could not save theme to localStorage.", e);
  }
}

/**
 * 处理主题切换按钮的点击事件
 */
function handleThemeToggleClick() {
  // 给根元素添加动画类(标识正在过渡动画)
  docElement.classList.add("is-animating");
  // 判断当前是否为暗色主题(根元素是否有dark类)
  const isDarkMode = docElement.classList.contains("dark");
  // 切换主题:取反当前状态
  updateTheme(!isDarkMode);
}

/**
 * 初始化主题状态(页面加载时执行)
 */
function initializeTheme() {
  // 获取当前根元素的dark类状态(判断初始主题)
  const isDarkMode = docElement.classList.contains("dark");
  // 更新主题(同步按钮状态和本地存储)
  updateTheme(isDarkMode);
}

// 补充:原代码缺失handleTransitionEnd函数,此处补充注释(需确保函数存在)
/**
 * 处理过渡动画结束事件(清除动画类)
 */
function handleTransitionEnd() {
  // 动画结束后移除is-animating类(可根据需求扩展)
  docElement.classList.remove("is-animating");
}

// 给切换按钮绑定点击事件:点击时触发主题切换
themeToggleButton.addEventListener("click", handleThemeToggleClick);
// 获取主题切换容器DOM元素(用于监听过渡结束)
const themeContainer = document.querySelector(".theme-toggle__container");
// 给容器绑定过渡结束事件:动画结束后执行回调
themeContainer.addEventListener("transitionend", handleTransitionEnd);
// 页面加载时初始化主题(同步状态)
initializeTheme();

代码下载

总结

本次实现的拟物化主题切换按钮,不仅兼顾了视觉美感和交互体验,还充分考虑了无障碍和鲁棒性。核心思路是「分层设计」——HTML 语义化分层、CSS 视觉分层、JS 交互分层,同时通过 CSS 变量和状态类实现灵活的主题切换。

拟物化设计并非简单的「模仿现实」,而是通过细节的光影、动画和交互,让界面更贴近人的直觉,提升用户体验。希望本文能为大家提供一个拟物化组件开发的完整思路,也欢迎大家基于此代码扩展更多有趣的交互效果。

完整代码已放在示例中,大家可以直接复制运行,也可以根据自己的审美和需求调整颜色、尺寸、动画时长等参数,打造属于自己的拟物化主题切换按钮。