电梯卡在半空不动弹?用纯CSS搞个会算楼层、会指方向的模拟器,到底怎么整?

4,138字
18–26 分钟
in

纯CSS能干啥?以前顶多改改颜色、加加动画。可看到有人拿复选框搭出完整状态机,心里就痒痒:能不能整点更带劲的,比如弄个会跑、会算楼层、还能指方向的电梯?真动手开搞才发现,现在CSS那套新玩意儿——自定义属性、计数器、:has()选择器、还有@property——凑一块,完全能支棱起一个不用JavaScript的智能电梯,它知道自己搁哪层、要去哪层、甚至路上得晃悠多久。

目录

怎么定义电梯的当前楼层

电梯得记住自己停在几楼。用CSS变量记录这个状态,再配合@property让浏览器能识别数字类型,后续的移动和计算才能丝滑起来。

@property --current-floor {
  syntax: "<integer>";
  initial-value: 1;
  inherits: true;
}

@property --previous {
  syntax: "<number>";
  initial-value: 1;
  inherits: true;
}

@property --relative-speed {
  syntax: "<number>";
  initial-value: 4;
  inherits: true;
}

@property --direction {
  syntax: "<integer>";
  initial-value: 0;
  inherits: true;
}

普通CSS变量在浏览器眼里就是一串字符,它分不清“5”到底是个楼层数字、一种颜色、还是猫的名字。分不清就没法做过渡动画。用@property注册变量,等于给浏览器递了个说明书:这是整数、那是数字,初始值多少、要不要继承。这么一搞,电梯从3楼到8楼就能顺滑地平移,而不是“啪”一下闪现过去。

按个按钮就动起来

每个楼层配一个单选按钮,用:has()来检测哪个按钮被选中,然后把对应的楼层号赋给变量。

<input type="radio" id="floor1" name="floor" value="1" checked>
<input type="radio" id="floor2" name="floor" value="2">
<input type="radio" id="floor3" name="floor" value="3">
<input type="radio" id="floor4" name="floor" value="4">
.elevator-system:has(#floor1:checked) {
  --current-floor: 1;
  --previous: var(--current-floor);
}

.elevator-system:has(#floor2:checked) {
  --current-floor: 2;
  --previous: var(--current-floor);
}

这套组合拳相当于给电梯装了个大脑,单选按钮的切换会触发变量的更新,后续的移动、方向指示全跟着联动。选楼层跟点外卖似的,按一下就有反应。

让电梯跑起来的速度咋算

移动靠transform: translateY,移动时长得根据跨了多少层来动态变化。不能1楼到2楼跟1楼到8楼一样快,那体验太假。

.elevator {
  transform: translateY(calc((1 - var(--current-floor)) * var(--floor-height)));
  transition: transform calc(var(--relative-speed) * 1s);
}

--abs: calc(abs(var(--current-floor) - var(--previous)));
--relative-speed: calc(1 + var(--abs));

--abs算出绝对层数差,--relative-speed让跨层越多动画越慢。从1楼到4楼,动画耗时比2楼到3楼长。这里头全是calc()在算数学题,没JS什么事。要是楼层差算错了,过渡时间就不对,得核对下--previous有没有及时更新,有时候它还停在老值上,导致速度计算翻车。

上下箭头怎么判断方向

电梯往哪走,箭头得指对方向。通过当前楼层减去之前楼层来判定,正数就是上,负数就是下。

--direction: clamp(-1, calc(var(--current-floor) - var(--previous)), 1);

.arrow {
  scale: calc(var(--direction) * 2);
  opacity: abs(var(--direction));
  transition: all 0.15s ease-in-out;
}

clamp()把差值卡死在-1到1之间,结果1代表向上,-1代表向下,0表示没动。这个值用来控制箭头缩放和透明度,向上时箭头朝上还亮着,向下时箭头反转并变淡。这招比写一堆JS判断省事多了,而且数学逻辑直接嵌在样式里。

楼层变化时的记忆问题

CSS没有“之前状态”这个概念,但可以让--previous延迟更新来模拟记忆效果。

.elevator-system {
  transition: --previous calc(var(--delay) * 1s);
  --delay: 1;
}

在延迟期间,--previous还是旧楼层,正好用来算方向和速度。等动画跑完,--previous才同步成最新楼层。这招就像拍了一下桌子,手抬起来了印子还在。不过延迟时长得调合适,太短算不出来,太长状态错位。可以按最大跨层来定个基准值,保证所有场景都能正确过渡。

楼层显示整点花活

用计数器显示楼层号,再配合自定义计数器样式搞点视觉花样。

#floor-display:before {
  counter-reset: display var(--current-floor);
  content: counter(display, top-display);
}

@counter-style top-display {
  system: cyclic;
  symbols: "278A" "2781" "2782" "2783";
  suffix: "";
}

278A2783对应带圈数字➊➋➌➍。这样电梯不显示干巴巴的“3”,而是带圈圈的数字,看着就舒坦。要是符号不够用,可以把symbols列表加长,需要几层加几个。

屏幕阅读器也得照顾到

CSS改不了DOM里的文字,但能用伪元素配合计数器给读屏软件喂内容。

<div class="sr-only" aria-live="polite" id="floor-announcer"></div>
#floor-announcer::before {
  counter-reset: floor var(--current-floor);
  content: "Now on floor " counter(floor);
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}

aria-live="polite"让读屏软件等当前朗读完再播报新楼层,不会打断。.sr-only类把内容藏起来但保留给辅助技术。这波操作下来,视力障碍的朋友也能知道电梯到几楼了。

多方案实现楼层控制

方案一:单选按钮直接控制

用单选按钮作为楼层选择器,每个按钮对应一个CSS变量赋值。

结构:一组input[type="radio"],每个包裹在对应楼层的样式容器里。

赋值逻辑:用:has()检测哪个单选按钮被选中,设置--current-floor为对应数值。

.elevator-system:has(#floor1:checked) { --current-floor: 1; }
.elevator-system:has(#floor2:checked) { --current-floor: 2; }
.elevator-system:has(#floor3:checked) { --current-floor: 3; }

状态同步:同时更新--previous为当前楼层,保证方向计算初始值正确。这种方案最直观,适合楼层固定的场景。

方案二:模拟按键面板(纯CSS状态记忆)

想模拟电梯里按键按下后常亮的效果,可以用:checked配合相邻兄弟选择器。

结构:隐藏的单选按钮 + 可见的<label>标签。

<input type="radio" id="btnFloor1" name="floorCall">
<label for="btnFloor1" class="floor-btn">1</label>

触发与反馈:checked时改变对应按钮样式,同时通过:has()更新全局变量。这种方案需要额外处理多选问题,因为单选按钮同一时间只能有一个被选中。可以用复选框模拟多楼层呼叫,但状态管理会更复杂,得用~通用兄弟选择器来传递状态。

方案三:外置按钮加嵌套容器

把楼层选择器和电梯显示区拆成两块独立容器,通过外层包装器统一管理状态。

结构:外层.elevator-system包裹选择器和电梯显示区。

状态传递:所有:has()规则都基于外层容器,单选按钮变化直接作用于.elevator-system的变量。

.elevator-system:has(#floor1:checked) {
  --current-floor: 1;
  --previous: var(--current-floor);
}

这种解耦方式适合复杂布局,选择器面板放左边,电梯动画放右边,互不干扰。但要注意外层容器的:has()选择器性能,选择器嵌套太深可能影响渲染效率。

不同方案的适用场景

方案优点局限
单选按钮直控结构简单 状态明确楼层固定 难扩展
模拟按键面板交互直观 视觉反馈单选限制 多选需改造
嵌套容器解耦布局灵活 易于维护选择器性能 调试略复杂

不管选哪种方案,核心都是靠:has()检测状态变化来更新变量,再用transition驱动动画。实际动手时建议先用第一种跑通基础逻辑,再根据需要切换到其他方案。踩过的坑里最烦人的是--previous更新时机,有时候动画跑完它还没变,方向箭头就乱指。解决方法是检查transition属性是否写在了正确的位置,确保延迟更新只在动画期间起作用。