Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Collapsible] Rework CSS transitions #1549

Closed
wants to merge 31 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
239c527
Add experiment
mj12albert Mar 10, 2025
d69830f
Support keepMounted false
mj12albert Mar 10, 2025
96644b6
Support hidden attribute
mj12albert Mar 10, 2025
37f2cc5
Fix Tailwind hidden attr default
mj12albert Mar 10, 2025
3c7c6a6
Cancel transition if initially open
mj12albert Mar 10, 2025
3479f5a
Remove unnecessary opacity changes
mj12albert Mar 11, 2025
d362b38
useTransitionStatus
mj12albert Mar 11, 2025
7904aa3
No flag
mj12albert Mar 11, 2025
6c96edd
Remove more manual opacity changes.
mj12albert Mar 11, 2025
149e15a
Tweak style tag cleanup
mj12albert Mar 11, 2025
4888f86
keepMounted true works well
mj12albert Mar 11, 2025
ce81c53
WIP other transitions wwhen keepMounted false
mj12albert Mar 11, 2025
72acf3e
WIP other transition properties
mj12albert Mar 11, 2025
0ffb27a
WIP handle other transition props
mj12albert Mar 12, 2025
57c2fe6
drop isInitiallyOpenRef
mj12albert Mar 12, 2025
2edc4eb
data-closed is not needed in CSS anymore
mj12albert Mar 12, 2025
669d9cf
remove extra flag on useTransitionStatus
mj12albert Mar 12, 2025
2fadd65
extend starting status
mj12albert Mar 12, 2025
b2836cd
remove one more manual opacity change
mj12albert Mar 12, 2025
c8cd8a4
a brilliant hack
mj12albert Mar 12, 2025
4841281
slight polish
mj12albert Mar 12, 2025
4754b49
rename
mj12albert Mar 12, 2025
ae5d2c8
Add demos
mj12albert Mar 12, 2025
e461391
Isolate CSS animation logic
mj12albert Mar 13, 2025
4743ec8
Merge reworked implementations
mj12albert Mar 18, 2025
cf7ddda
WIP rework hidden-until-found
mj12albert Mar 18, 2025
2c1b53f
WIP 2
mj12albert Mar 18, 2025
4fb41b4
WIP 3
mj12albert Mar 18, 2025
f8d0147
WIP 4
mj12albert Mar 19, 2025
00f99b8
CSS animation fixes
mj12albert Mar 19, 2025
5c9a159
Handle beforematch with CSS animations
mj12albert Mar 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/src/app/(private)/experiments/collapsible/_icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client';
import * as React from 'react';

export default function Nothing() {
return <div>This is just a dummy file to hold icons</div>;
}

export function ExpandMoreIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
{...props}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" fill="currentColor" />
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client';
import * as React from 'react';
import { Collapsible } from '@base-ui-components/react/collapsible';
import styles from './animation.module.css';

export default function CollapsibleCssAnimation() {
return (
<div className={styles.wrapper}>
<Collapsible.Root defaultOpen className={styles.Root}>
<Collapsible.Trigger className={styles.Trigger}>Trigger</Collapsible.Trigger>
<Collapsible.Panel className={styles.Panel} keepMounted>
<div className={styles.Content}>
<p>
He rubbed his eyes, and came close to the picture, and examined it
again. There were no signs of any change when he looked into the actual
painting, and yet there was no doubt that the whole expression had
altered. It was not a mere fancy of his own. The thing was horribly
apparent.
</p>
</div>
</Collapsible.Panel>
</Collapsible.Root>

<Collapsible.Root defaultOpen={false} className={styles.Root}>
<Collapsible.Trigger className={styles.Trigger}>Trigger</Collapsible.Trigger>
<Collapsible.Panel className={styles.Panel} keepMounted>
<div className={styles.Content}>
<p>
He rubbed his eyes, and came close to the picture, and examined it
again. There were no signs of any change when he looked into the actual
painting, and yet there was no doubt that the whole expression had
altered. It was not a mere fancy of his own. The thing was horribly
apparent.
</p>
</div>
</Collapsible.Panel>
</Collapsible.Root>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
@keyframes slide-down {
from {
height: 0;
}

to {
height: var(--collapsible-panel-height);
}
}

@keyframes slide-up {
from {
height: var(--collapsible-panel-height);
}

to {
height: 0;
}
}

.Panel {
overflow: hidden;
box-sizing: border-box;
width: 100%;

&[data-open] {
animation: slide-down var(--duration) ease-out;
}

&[data-closed] {
animation: slide-up var(--duration) ease-in;
}
}

/* the styles below are irrelevant to the features */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 5rem;
}

.wrapper {
font-family: system-ui, sans-serif;
line-height: 1.4;
display: flex;
flex-flow: column nowrap;
align-items: stretch;
gap: 1rem;
align-self: flex-start;
}

.Root {
--width: 320px;
--duration: 900ms;

width: var(--width);

& + .Root {
margin-top: 2rem;
}
}

.Trigger {
display: flex;
width: 100%;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background-color: var(--color-gray-200);
color: var(--color-gray-900);

& svg {
transform: rotate(-90deg);
transition: transform var(--duration) ease-in;
}

&[data-panel-open] svg {
transform: rotate(0);
transition: transform var(--duration) ease-out;
}
}

.Content {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.25rem;
padding: 0.5rem;
border-radius: 0.25rem;
background-color: var(--color-gray-200);
cursor: text;

& p {
overflow-wrap: break-word;
}
}
154 changes: 154 additions & 0 deletions docs/src/app/(private)/experiments/collapsible/animation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
'use client';
import * as React from 'react';
import {
useEnhancedEffect,
// useTransitionStatus,
} from '@base-ui-components/react/utils';
import classes from './animation.module.css';

import { useAnimationsFinished } from '../../../../../../packages/react/src/utils/useAnimationsFinished';
import { useEventCallback } from '../../../../../../packages/react/src/utils/useEventCallback';
import { useOnMount } from '../../../../../../packages/react/src/utils/useOnMount';
import { ExpandMoreIcon } from './_icons';

function Collapsible(props: {
defaultOpen?: boolean;
keepMounted?: boolean;
id?: string;
}) {
const { keepMounted = true, defaultOpen = false, id } = props;

const [open, setOpen] = React.useState(defaultOpen);

const [visible, setVisible] = React.useState(open);

const [mounted, setMounted] = React.useState(open);

const [height, setHeight] = React.useState<number | undefined>(0);

const latestAnimationNameRef = React.useRef<string>(null);
const shouldCancelInitialOpenAnimationRef = React.useRef(open);

const isHidden = !visible;

const panelRef: React.RefObject<HTMLElement | null> = React.useRef(null);

const runOnceAnimationsFinish = useAnimationsFinished(panelRef, false);

const handleTrigger = useEventCallback(() => {
const nextOpen = !open;

const panel = panelRef.current;

if (panel) {
panel.style.removeProperty('animation-name');
}

if (!keepMounted) {
if (!visible && nextOpen) {
setVisible(true);
}
if (!mounted && nextOpen) {
setMounted(true);
}
}
setOpen(nextOpen);
});

useEnhancedEffect(() => {
const panel = panelRef.current;
if (!panel) {
return;
}

latestAnimationNameRef.current =
panel.style.animationName || latestAnimationNameRef.current;

panel.style.animationName = 'none';

setHeight(panel.scrollHeight);

if (!shouldCancelInitialOpenAnimationRef.current) {
panel.style.removeProperty('animation-name');
}

if (open) {
setMounted(true);
}

runOnceAnimationsFinish(() => {
setVisible(open);
});
}, [open, visible, runOnceAnimationsFinish]);

useOnMount(() => {
const frame = requestAnimationFrame(() => {
shouldCancelInitialOpenAnimationRef.current = false;
});
return () => cancelAnimationFrame(frame);
});

return (
<div
className={classes.Root}
style={{
// @ts-ignore
'--collapsible-panel-height': height ? `${height}px` : undefined,
}}
>
<button
type="button"
className={classes.Trigger}
data-panel-open={open || undefined}
onClick={handleTrigger}
>
<ExpandMoreIcon className={classes.Icon} />
Trigger {id}
</button>

{(keepMounted || (!keepMounted && mounted)) && (
<div
// @ts-ignore
ref={panelRef}
className={classes.Panel}
{...{ [open ? 'data-open' : 'data-closed']: '' }}
// {...styleHooks}
hidden={isHidden}
id={id}
>
<div className={classes.Content}>
<p>
He rubbed his eyes, and came close to the picture, and examined it
again. There were no signs of any change when he looked into the actual
painting, and yet there was no doubt that the whole expression had
altered. It was not a mere fancy of his own. The thing was horribly
apparent.
</p>
</div>
</div>
)}
</div>
);
}

export default function App() {
return (
<div className={classes.grid}>
<div className={classes.wrapper}>
<pre>keepMounted: true</pre>
<Collapsible keepMounted defaultOpen id="1" />

<Collapsible keepMounted defaultOpen={false} id="2" />

<small>———</small>
</div>
<div className={classes.wrapper}>
<pre>keepMounted: false</pre>
<Collapsible keepMounted={false} defaultOpen id="3" />

<Collapsible keepMounted={false} defaultOpen={false} id="4" />
<small>———</small>
</div>
</div>
);
}
Loading