半模态高度自适应,我一直觉得是个“反直觉”的命题。以前做弹层组件,产品需求里写着“高度随内容自适应”,听起来很简单,一旦你真拿它去实现,就会发现页面里几乎每个结构都要为这句话服务,踩坑点密集。这篇文章想把半模态高度自适应的原理、CSS方案、JS测量方案、小程序和uni-app场景下的处理方式完整拆一遍,顺便把 flex 布局子元素宽度自适应、scroll-view 剩余高度计算、CSS 高度为宽度 50% 这类常被一起搜索的兄弟问题讲清楚,适合正在写弹层组件或者改造旧弹层的人参考。
1. 半模态不是半个弹窗:它要自适应的是哪一维高度
1.1 半模态形态与“半”字的误解
半模态在移动端产品里出现频率极高。最常见的形态是底部弹层,比如分享面板、筛选器、商品规格选择,桌面端也有类似对话框。很多人一听“半模态”,第一反应是高度只有屏幕一半,这种理解会把实现方向带偏。半模态的“半”,语义更接近“保留上下文”,不是把页面切成两半。它允许主界面仍然可见,让用户知道自己在哪一屏,而不是像全屏弹窗那样把前一个页面完全盖住。
正因为这样,半模态的高度就很难用固定值表达。iPhone 的可用高度和 Android 不同,同尺寸下有没有底部小黑条也不同,页面里如果还有自定义导航栏或底部 TabBar,可分配高度又会被压缩一块。写死 600px 或写死 70vh,短期看能用,换一款机型或改一版视觉稿就露馅。所以你会看到大量“半模态高度能否自适应”的搜索,背后都是同一个诉求:弹层高度可以跟着内容走,在极端情况下又不会溢出屏幕。
这个诉求里藏着一个常见误区。很多人认为,只要把半模态根节点的高度设为 auto,让内容自己撑开就是自适应。实际开发中,auto 撑开只是最基础的一步,距离“可用”还很远。移动端底部弹层通常带内部滚动区,弹层弹出后主页面不能跟着滚,内容很长时弹层内部要有独立滚动;同时底部安全区要留白,顶部的圆角区域又不能被状态栏挡住。没有上限、没有滚动规则、没有安全区处理的“纯 auto”,在真机上不是自适应,是裸奔。
1.2 自适应不是一个方向,而是两条线同时作用
半模态高度自适应的本质,可以拆成两条线。第一条线是“容器随内容长高”,第二条线是“内容随容器收缩”。
第一条线处理内容少的情况。比如分享面板只有两行按钮,弹层应该是紧凑的一小坨,而不是占满半屏。第二条线处理内容多的情况。比如动态加载了一批筛选条件后,内容超过了屏幕可用高度,弹层外框要封顶,内部出现滚动条,而不是把底部按钮顶出屏幕或者让背景页面跟着滚动。
这两条线不是互相替代的关系,而是同一套组件在不同时刻呈现的不同状态。早期我做弹层时,习惯把整个弹层当成一个巨大的 scroll-view,觉得最省事。可用性很差:用户想操作底部“确定”按钮时,要先滚过一长串内容;而且弹层滚动会吃掉背景页面的手势,关闭手势和滚动手势冲突得很厉害。后来改成 header、可滚动内容区、footer 三段式才顺眼。而三段式布局恰恰要求高度计算更精确:外部只负责封顶,header 和 footer 固定,中间那一块才是真正的滚动区。
理解了这两条线,再看各种技术方案就清晰了。纯 CSS 方案适合容器上限比较宽松、内容高度相对稳定的场景;JS 动态测量适合内容异步变化、图片加载、节点增删导致高度不确定的场景;而小程序这种没有 DOM 测量能力的平台,还要额外依赖 SelectorQuery 这类 API 去补“内容真实高度”的缺失。下面几章按这条路径来展开。
1.3 弹层高度优先级的三个原则
我在项目里给半模态高度定过一组优先级,可以帮你判断某个方案对不对。第一,高度只能封顶,不能写死;第二,弹层内部滚动区要把 footer 或关键操作按钮留在可视区;第三,所有涉及自适应的地方都要考虑键盘和安全区这两个“外部变量”。
这三点听起来简单,却会在细节上反复挑战你。比如只给外层加了 max-height,但 flex 子项没有处理好,内容一多照样溢出圆角区域。又比如动态算出高度之后,每次内容变化都触发过渡动画,弹层看起来像在呼吸,体验反而很差。这些问题不是某个框架特有的,而是所有带滚动和自适应的 UI 组件都会遇到的通病。后面我会在具体实现里反复回到这三条原则。先抛个结论:半模态高度能自适应,但别指望一个 CSS 属性解决,通常需要“外层限高 + 内容测量 + 滚动区兜底”三者配合。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 纯CSS的先决条件:从固定height改成max-height,再谈flex和aspect-ratio
2.1 基础结构:把 height 换掉,先让弹层“能长也能缩”
先看一个最简 Web 半模态结构:遮罩加底部面板。面板里是三段内容:标题区、可滚动内容区、底部操作区。很多人的第一版是这么写的:
css复制.half-modal {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 60vh; /* 第一次实现时拍脑袋写的高度 */
}
这段代码最开始可能还能用,等产品把内容从三个选项改成十个选项,问题就出现了。60vh 在全面屏手机上可能太高,在小屏手机又可能不够装。正确的基础写法是去掉固定 height,改成 max-height,让内容自己决定最终高度:
css复制.half-modal {
position: fixed;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
max-height: min(85dvh, calc(100dvh - 48px));
display: flex;
flex-direction: column;
}
.half-modal__body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
这里有几个细节值得展开。max-height 用的是 min(85dvh, calc(100dvh - 48px)),意思是“不能超过视口高度的 85%,同时顶部至少留 48px 给点击遮罩关闭”。dvh 和 vh 的区别在于移动端地址栏收起展开时,dvh 能跟随动态视口变化,传统 vh 在某些浏览器里会偏大或偏小。项目如果不用考虑太老的内核,可以直接上 dvh。
外层设了 max-height,但没显式给 height。内容不多时,面板高度等于内容高度;内容一多,max-height 会把面板封顶。flex 布局负责分配空间:header 和 footer 默认不会被压缩,body 的 flex: 1 和 min-height: 0 让它能在剩余空间不足时变成滚动区域。很多人漏掉的就是 min-height: 0。flex 子项的默认 min-height 是 auto,含义是“不小于内容高度”,这一条会让长内容把面板撑破,max-height 直接失效。
提示:用 flex 做弹层三段式时,
min-height: 0和overflow-y: auto必须成对出现。
