使用 grid-template-rows 实现平滑折叠动画
分享一种利用 CSS Grid 的 grid-template-rows 属性实现折叠动画的方法。这种方法相比传统的 max-height 过渡更加优雅,不需要计算具体高度值,而且能实现真正的线性过渡效果。
省流版
利用 grid-template-rows 来实现 Collapsible 组件,可以省去计算高度的过程。用 React 实现如下:
import React from 'react';
import {createStyleSheet} from 'libs/jss';
import clsx from 'clsx';
interface Props {
children: React.ReactNode;
collapsed?: boolean;
className?: string;
}
export const Collapsible: React.FC<Props> = (props) => {
return (
<div className={clsx(classes.wrapper, props.collapsed && classes.collapsed, props.className)}>
<div className={classes.layoutWrapper}>
{props.children}
</div>
</div>
);
};
const classes = createStyleSheet({
wrapper: {
display: 'grid',
gridTemplateRows: '1fr',
transition: 'grid-template-rows 0.3s ease-out',
},
collapsed: {
transition: 'grid-template-rows 0.3s ease-in',
gridTemplateRows: '0fr',
},
layoutWrapper: {
overflow: 'hidden',
},
});
核心方法
实现原理
这个折叠组件的核心在于 CSS Grid 布局的巧妙运用:
- 父元素:使用
display: grid和grid-template-rows控制行高 - 过渡效果:对
grid-template-rows属性应用 CSS 过渡 - 子元素:设置
overflow: hidden和min-height: 0。这样,当父元素0fr时,尺寸计算为 0,自己 overflow: hidden 可以保证子元素的内容起不到“支撑高度”的作用
为什么需要 overflow: hidden?
这是整个实现的关键所在。
当父元素 0fr 时,尺寸理论计算结果为 0。子元素设置 overflow: hidden,可以保证孙子元素的内容起不到“支撑高度”的作用
但是为什么不能在父元素直接设置 overflow: hidden 呢?为什么一定需要父子两个元素配合呢?暂时不知道原理。
为什么是 0fr → 1fr 而不是 0 → 1fr?
0 -> 1fr 并不是一个平滑过渡。而 0fr -> 1fr 单位相同属于平滑过渡
演示
下面是 Web Component 实现的在线演示版本:
<!-- pg-title: Collapsible Web Component 演示 -->
<h2>基本使用示例</h2>
<collapsible-element>
<div style="padding: 16px; background: #f0f0f0; border-radius: 4px;">
这是一个默认展开的折叠内容。点击下面的按钮可以切换状态。
</div>
</collapsible-element>
<button onclick="toggleFirst()">切换第一个折叠组件</button>
<h2>默认折叠状态</h2>
<collapsible-element collapsed>
<div style="padding: 16px; background: #e0e0ff; border-radius: 4px;">
这个内容默认是折叠状态的。
</div>
</collapsible-element>
<button onclick="toggleSecond()">切换第二个折叠组件</button>
<h2>多个内容示例</h2>
<collapsible-element id="complex-example">
<div style="padding: 16px;">
<h3>标题</h3>
<p>这是一段较长的内容,用来演示折叠组件如何处理多行文本和复杂布局。</p>
<ul>
<li>列表项 1</li>
<li>列表项 2</li>
<li>列表项 3</li>
</ul>
</div>
</collapsible-element>
<button onclick="toggleThird()">切换复杂示例</button>
<script>
function toggleFirst() {
const element = document.querySelector('collapsible-element');
element.collapsed = !element.collapsed;
}
function toggleSecond() {
const element = document.querySelectorAll('collapsible-element')[1];
element.collapsed = !element.collapsed;
}
function toggleThird() {
const element = document.getElementById('complex-example');
element.collapsed = !element.collapsed;
}
</script>
/* pg-editor-default-open: false */
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
button {
background: #007acc;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
margin: 8px 0;
}
button:hover {
background: #005a9e;
}
h2 {
color: #333;
margin-top: 2em;
}
// pg-editor-default-open: false
class CollapsibleElement extends HTMLElement {
static get observedAttributes() {
return ['collapsed'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.wrapper {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.3s ease-out;
}
.wrapper.collapsed {
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in;
}
.layout-wrapper {
overflow: hidden;
min-height: 0;
}
::slotted(*) {
margin: 0;
}
</style>
<div class="wrapper">
<div class="layout-wrapper">
<slot></slot>
</div>
</div>
`;
this.wrapper = this.shadowRoot.querySelector('.wrapper');
}
connectedCallback() {
// 初始同步状态
if (this.hasAttribute('collapsed')) {
this.wrapper.classList.add('collapsed');
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'collapsed') {
if (newValue !== null) {
this.wrapper.classList.add('collapsed');
} else {
this.wrapper.classList.remove('collapsed');
}
}
}
get collapsed() {
return this.hasAttribute('collapsed');
}
set collapsed(value) {
if (value) {
this.setAttribute('collapsed', '');
} else {
this.removeAttribute('collapsed');
}
}
}
customElements.define('collapsible-element', CollapsibleElement);
背景
今天在分析一个竞品的页面,偶然发现他们的展开动画是使用 grid-template-rows 的实现。让我觉得很神奇。
简单分析后发现,过渡属性时 0fr -> 1fr。我简单写了个 demo,发现一点效果也没有。
我最初以为是整个折叠单元需要放在 flex 容器中,也就是需要三层结构。折叠元素需要设置 flex-grow: 1,这样在空间不足时元素会被自动压缩。但实际测试后发现这并不奏效,因为孙子元素有具体高度时,flex 布局不会压缩内容。潜意识了,也觉得这样做不对。
搞了半天,脑袋都昏了。死活没发现竟然时那个关键的 overflow: hidden 属性。简单问了 DeepSeek 后,才注意到这个 overflow: hidden。不过其中的核心原理,依然不是很能理解。
总结
使用 grid-template-rows 实现折叠组件是一种巧妙且高效的方法,它避免了传统方法中需要计算具体高度的麻烦,同时提供了平滑的动画效果。关键是要记住设置 overflow: hidden 来确保内容正确折叠,这是很多开发者容易忽略的重要细节。
希望这个 Web Component 版本能帮助你在项目中快速实现类似的折叠效果!