06カテゴリーツリー展開 — 子の数で高さを求め直すリスト
カテゴリーが展開された状態になると、中に入っている子項目の数に項目1つの高さの値を掛けてリスト全体の高さを求め直し、その値まで伸びます。
使いどころフィルターパネル内のカテゴリーツリー、多段階の商品分類フィルター
scrollHeightsetPropertyaria-expanded

<main class="stage">
<div class="tree">
<button class="tree__toggle" id="toggle" type="button" aria-expanded="false" aria-controls="list">
<svg class="tree__caret" viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
カテゴリー
</button>
<ul class="tree__list is-demo" id="list">
<li>レディース衣料 (128)</li>
<li>メンズ衣料 (86)</li>
<li>アクセサリー (42)</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.tree {
box-sizing: border-box;
width: min(80vw, 220px);
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: $subject-cream;
color: $color;
}
.tree__toggle {
display: flex;
align-items: center;
gap: $sp-2;
width: 100%;
border: 0;
background: transparent;
padding: 0;
color: $color;
font-family: inherit;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
cursor: pointer;
}
.tree__caret {
flex: 0 0 auto;
transition: transform $duration $easing;
}
.tree__toggle[aria-expanded="true"] .tree__caret {
transform: rotate(180deg);
}
.tree__list {
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
height: 0;
transition: height $duration $easing;
}
.tree__list.is-open {
height: var(--tree-height, auto);
}
.tree__list li {
padding: $sp-2 $sp-1 $sp-2 $sp-6;
font-size: 12px;
line-height: 1.4;
color: rgba($color, .8);
}
.tree__list.is-demo {
animation-name: tree-loop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes tree-loop {
0%, 18% { height: 0; }
45%, 75% { height: var(--tree-height, 100px); }
100% { height: 0; }
}
@media (max-width: 340px) {
.tree { width: 84vw; padding: $sp-2 $sp-3; }
.tree__list li { padding-left: $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.tree__list, .tree__caret { transition: none; }
.tree__list.is-demo { animation: none; height: var(--tree-height, auto); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tree {
box-sizing: border-box;
width: min(80vw, 220px);
padding: 12px 16px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
}
.tree__toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
border: 0;
background: transparent;
padding: 0;
color: #17141a;
font-family: inherit;
font-weight: 600;
font-size: 13px;
line-height: 1.4;
cursor: pointer;
}
.tree__caret {
flex: 0 0 auto;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tree__toggle[aria-expanded=true] .tree__caret {
transform: rotate(180deg);
}
.tree__list {
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
height: 0;
transition: height 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tree__list.is-open {
height: var(--tree-height, auto);
}
.tree__list li {
padding: 8px 4px 8px 24px;
font-size: 12px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.8);
}
.tree__list.is-demo {
animation-name: tree-loop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes tree-loop {
0%, 18% {
height: 0;
}
45%, 75% {
height: var(--tree-height, 100px);
}
100% {
height: 0;
}
}
@media (max-width: 340px) {
.tree {
width: 84vw;
padding: 8px 12px;
}
.tree__list li {
padding-left: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.tree__list, .tree__caret {
transition: none;
}
.tree__list.is-demo {
animation: none;
height: var(--tree-height, auto);
}
}// 하위 목록의 실제 렌더 높이(scrollHeight)를 재서 --tree-height 로 저장(setProperty)해 두고,
// 펼침 상태는 그 값을 height 로 삼아 transition·keyframe 이 0 에서 그 값까지 보간한다.
// 실제 클릭: aria-expanded 를 뒤집고 목록을 펼치거나 접는다.
(function () {
var toggle = document.getElementById('toggle');
var list = document.getElementById('list');
if (!toggle || !list) return;
function measure() {
list.style.setProperty('--tree-height', list.scrollHeight + 'px');
}
measure();
toggle.addEventListener('click', function () {
list.classList.remove('is-demo');
measure();
var open = list.classList.toggle('is-open');
toggle.setAttribute('aria-expanded', String(open));
});
window.addEventListener('resize', measure);
})();import { useEffect, useRef } from "react";
import styles from "./CategoryTreeExpand.module.scss";
// 하위 목록의 실제 렌더 높이(scrollHeight)를 재서 --tree-height 로 저장(setProperty)해 두고,
// 펼침 상태는 그 값을 height 로 삼아 transition·keyframe 이 0 에서 그 값까지 보간한다.
// 실제 클릭: aria-expanded 를 뒤집고 목록을 펼치거나 접는다.
export default function CategoryTreeExpand() {
const toggleRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
const toggle = toggleRef.current;
const list = listRef.current;
if (!toggle || !list) return;
function measure() {
list!.style.setProperty("--tree-height", list!.scrollHeight + "px");
}
measure();
function onClick() {
list!.classList.remove(styles["is-demo"]);
measure();
const open = list!.classList.toggle(styles["is-open"]);
toggle!.setAttribute("aria-expanded", String(open));
}
toggle.addEventListener("click", onClick);
window.addEventListener("resize", measure);
return () => {
toggle.removeEventListener("click", onClick);
window.removeEventListener("resize", measure);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.tree}>
<button
ref={toggleRef}
className={styles.tree__toggle}
type="button"
aria-expanded="false"
aria-controls="list"
>
<svg className={styles.tree__caret} viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
카테고리
</button>
<ul ref={listRef} id="list" className={`${styles.tree__list} ${styles["is-demo"]}`}>
<li>여성 의류 (128)</li>
<li>남성 의류 (86)</li>
<li>액세서리 (42)</li>
</ul>
</div>
</main>
);
}06 カテゴリーツリー展開 — 子の数で高さを求め直すリスト — 計測 累積面積 16.82% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · 絞り込み ui9選 — パネルスライドからチップ削除まで
このデモが属する記事
絞り込み ui9選 — パネルスライドからチップ削除まで絞り込み uiは、実は一つの動きではなく複数の小さな計算でできています。9つのうち1つはチップが消えた分だけ残るチップの合計幅を求め直して詰め、1つはアクティブなフィルター数の変化量でバッジを膨らませてから戻し、もう1つは2つのスライダー値 この記事のデモをすべて見る

