..

使用 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: gridgrid-template-rows 控制行高
  • 过渡效果:对 grid-template-rows 属性应用 CSS 过渡
  • 子元素:设置 overflow: hiddenmin-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 版本能帮助你在项目中快速实现类似的折叠效果!