UI Components Medium
Spring Accordion
An accordion component with a spring-like open/close animation. Uses CSS grid-template-rows trick for smooth height transitions — no max-height hacks.
Open in Lab
MCP
css vanilla-js css-grid transition
Targets: JS HTML React Native
Expo Snack
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f172a;
color: #f1f5f9;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.accordion-wrapper {
width: min(600px, 100%);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.accordion-title {
font-size: 1.375rem;
font-weight: 700;
color: #f1f5f9;
}
/* ── Accordion ── */
.accordion {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.accordion-item {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.875rem;
overflow: hidden;
transition: border-color 0.2s ease;
}
.accordion-item.open {
border-color: rgba(56, 189, 248, 0.2);
}
/* ── Trigger button ── */
.accordion-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.125rem 1.25rem;
background: transparent;
border: none;
color: #e2e8f0;
font-size: 0.9375rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: color 0.15s ease;
}
.accordion-trigger:hover {
color: #f8fafc;
}
.accordion-item.open .accordion-trigger {
color: #f8fafc;
}
/* ── Chevron ── */
.accordion-chevron {
flex-shrink: 0;
color: #475569;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), color 0.15s ease;
}
.accordion-item.open .accordion-chevron {
transform: rotate(180deg);
color: #38bdf8;
}
/* ── Panel — grid-template-rows trick ── */
.accordion-panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.accordion-item.open .accordion-panel {
grid-template-rows: 1fr;
}
@media (prefers-reduced-motion: reduce) {
.accordion-panel {
transition: none;
}
.accordion-chevron {
transition: none;
}
}
/* ── Panel inner (overflow clip) ── */
.accordion-panel-inner {
overflow: hidden;
}
.accordion-panel-inner p {
padding: 0 1.25rem 1.25rem;
font-size: 0.9rem;
color: #94a3b8;
line-height: 1.7;
}
.accordion-panel-inner code {
font-family: "Fira Code", monospace;
font-size: 0.85em;
background: rgba(56, 189, 248, 0.1);
color: #7dd3fc;
padding: 0.1em 0.4em;
border-radius: 0.3em;
}(function () {
"use strict";
const accordion = document.getElementById("accordion");
if (!accordion) return;
const items = accordion.querySelectorAll(".accordion-item");
items.forEach((item) => {
const trigger = item.querySelector(".accordion-trigger");
const panel = item.querySelector(".accordion-panel");
trigger.addEventListener("click", () => {
const isOpen = item.classList.contains("open");
// Close all items (single-open accordion)
items.forEach((other) => {
if (other !== item) closeItem(other);
});
// Toggle clicked item
isOpen ? closeItem(item) : openItem(item);
});
});
function openItem(item) {
const trigger = item.querySelector(".accordion-trigger");
const panel = item.querySelector(".accordion-panel");
item.classList.add("open");
trigger.setAttribute("aria-expanded", "true");
panel.setAttribute("aria-hidden", "false");
}
function closeItem(item) {
const trigger = item.querySelector(".accordion-trigger");
const panel = item.querySelector(".accordion-panel");
item.classList.remove("open");
trigger.setAttribute("aria-expanded", "false");
panel.setAttribute("aria-hidden", "true");
}
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spring Accordion</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="accordion-wrapper">
<h2 class="accordion-title">Frequently Asked Questions</h2>
<div class="accordion" id="accordion">
<div class="accordion-item open">
<button class="accordion-trigger" aria-expanded="true">
<span>What is the CSS grid-template-rows trick?</span>
<svg class="accordion-chevron" aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<div class="accordion-panel" aria-hidden="false">
<div class="accordion-panel-inner">
<p>Animating <code>grid-template-rows</code> from <code>0fr</code> to <code>1fr</code> allows you to transition an element from zero to its natural content height — without JavaScript measurements or the <code>max-height</code> hack. The inner element uses <code>overflow: hidden</code> to clip during the transition.</p>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-trigger" aria-expanded="false">
<span>Why not just animate max-height?</span>
<svg class="accordion-chevron" aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<div class="accordion-panel" aria-hidden="true">
<div class="accordion-panel-inner">
<p>The <code>max-height</code> approach requires setting an arbitrary large value (e.g., <code>1000px</code>), which causes the easing curve to feel wrong — the animation "hangs" at the end because it's still transitioning through invisible space. The grid trick avoids this entirely.</p>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-trigger" aria-expanded="false">
<span>How does the spring easing work?</span>
<svg class="accordion-chevron" aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<div class="accordion-panel" aria-hidden="true">
<div class="accordion-panel-inner">
<p>The curve <code>cubic-bezier(0.34, 1.56, 0.64, 1)</code> goes slightly past 1.0 in its Y range, creating a real overshoot effect. This is the closest you can get to a true spring with CSS without JavaScript. Combine it with a 400ms duration for a natural feel.</p>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-trigger" aria-expanded="false">
<span>Is it accessible?</span>
<svg class="accordion-chevron" aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<div class="accordion-panel" aria-hidden="true">
<div class="accordion-panel-inner">
<p>Yes. Each trigger uses a <code><button></code> with <code>aria-expanded</code> toggled by JS. The panel has <code>aria-hidden</code> synced to the open state. The component also respects <code>prefers-reduced-motion</code> — when set, transitions are instant.</p>
</div>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import React, { useRef, useState } from "react";
import {
Animated,
LayoutChangeEvent,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
interface AccordionItemData {
title: string;
content: string;
}
function AccordionItem({
item,
isOpen,
onToggle,
}: {
item: AccordionItemData;
isOpen: boolean;
onToggle: () => void;
}) {
const animatedHeight = useRef(new Animated.Value(0)).current;
const chevronRotation = useRef(new Animated.Value(0)).current;
const contentHeight = useRef(0);
const hasOpened = useRef(false);
const onLayout = (e: LayoutChangeEvent) => {
const h = e.nativeEvent.layout.height;
if (h > 0) {
contentHeight.current = h;
if (!hasOpened.current && isOpen) {
animatedHeight.setValue(h);
chevronRotation.setValue(1);
hasOpened.current = true;
}
}
};
const toggle = () => {
onToggle();
const toOpen = !isOpen;
hasOpened.current = true;
Animated.spring(animatedHeight, {
toValue: toOpen ? contentHeight.current : 0,
useNativeDriver: false,
tension: 80,
friction: 10,
}).start();
Animated.spring(chevronRotation, {
toValue: toOpen ? 1 : 0,
useNativeDriver: true,
tension: 80,
friction: 10,
}).start();
};
const rotate = chevronRotation.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "180deg"],
});
return (
<View style={styles.item}>
<TouchableOpacity style={styles.itemHeader} onPress={toggle} activeOpacity={0.7}>
<Text style={styles.itemTitle}>{item.title}</Text>
<Animated.Text style={[styles.chevron, { transform: [{ rotate }] }]}>▼</Animated.Text>
</TouchableOpacity>
<Animated.View style={[styles.panelClip, { height: animatedHeight }]}>
<View onLayout={onLayout} style={styles.panelContent}>
<Text style={styles.contentText}>{item.content}</Text>
</View>
</Animated.View>
</View>
);
}
const FAQ_ITEMS: AccordionItemData[] = [
{
title: "What is React Native?",
content:
"React Native is a framework for building native mobile applications using JavaScript and React. It lets you compose a rich mobile UI from declarative components, and renders directly to native platform views — no WebView involved.",
},
{
title: "How does the Animated API work?",
content:
"The Animated API provides a set of value types (Animated.Value) and animation drivers (spring, timing, decay) that operate on those values. You bind animated values to style props, and the framework efficiently updates the native views without going through the React reconciler on every frame.",
},
{
title: "Can I use this on both iOS and Android?",
content:
"Yes. React Native targets both iOS and Android from a single codebase. Platform-specific code can be isolated using Platform.select() or file extensions like .ios.tsx and .android.tsx when needed.",
},
{
title: "What about performance?",
content:
"React Native uses the native driver (useNativeDriver: true) to offload animations to the UI thread, keeping the JS thread free. For layout animations (like height changes), the JS-driven approach is used, which is still performant for most use cases.",
},
];
export default function App() {
const [openIndices, setOpenIndices] = useState<Set<number>>(new Set([0]));
const toggleIndex = (index: number) => {
setOpenIndices((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
return (
<View style={styles.container}>
<Text style={styles.header}>FAQ</Text>
<Text style={styles.subheader}>Tap a question to expand</Text>
<View style={styles.list}>
{FAQ_ITEMS.map((item, index) => (
<AccordionItem
key={index}
item={item}
isOpen={openIndices.has(index)}
onToggle={() => toggleIndex(index)}
/>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f172a",
paddingTop: 60,
paddingHorizontal: 20,
},
header: {
color: "#f8fafc",
fontSize: 28,
fontWeight: "700",
},
subheader: {
color: "#64748b",
fontSize: 14,
marginTop: 4,
marginBottom: 24,
},
list: {
gap: 8,
},
item: {
backgroundColor: "#1e293b",
borderRadius: 12,
overflow: "hidden",
},
itemHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 16,
},
itemTitle: {
color: "#f8fafc",
fontSize: 15,
fontWeight: "600",
flex: 1,
marginRight: 12,
},
chevron: {
color: "#818cf8",
fontSize: 12,
},
panelClip: {
overflow: "hidden",
},
panelContent: {
position: "absolute",
top: 0,
left: 0,
right: 0,
paddingHorizontal: 16,
paddingBottom: 16,
},
contentText: {
color: "#94a3b8",
fontSize: 14,
lineHeight: 22,
},
});Spring Accordion
An accordion that uses the CSS grid-template-rows: 0fr / 1fr trick to animate from zero to auto height — no max-height hacks, no JavaScript height measurements. The spring feel comes from a custom cubic-bezier easing curve.
How it works
The panel content is wrapped in two elements:
<div class="panel" aria-hidden="true"> <!-- grid rows transition here -->
<div class="panel-inner">…</div> <!-- overflow: hidden here -->
</div>
Toggling an open class switches grid-template-rows between 0fr and 1fr. The cubic-bezier(0.34, 1.56, 0.64, 1) curve gives a slight overshoot — the spring feel.
Features
- Smooth
0 → autoheight animation with no JS measurements - Single open at a time (closes others on open)
- Animated chevron rotation
aria-expanded+aria-hiddenfor accessibility
When to use it
- FAQ sections
- Settings panels
- Mobile navigation menus