React collapsed is a handy library for creating Collapsible components. It is a headless library, meaning rather than exposing fully styled or minimally styled components it gives us a hook which we can use to create our own Collapsible components. The benefit of such libraries is that you don't have to fight with the internal styles of the component. Styling the components is on the consumer of the library while the logic and state is managed by the hook. Reading through the react-collapsed library code, in this tutorial we will -
- First create a (rigid) Collapsible component.
- Refactor our component into a (generic) hook.
Please check the working demo here.
Step One - Project setup
Create a react typescript project, from your terminal run and set the project up by completing the simple questionnaire -
npm create vite@latest react-collapse
Get rid of all the default boilerplate code. Create 2 new files namely Collapse.tsx
and utils.ts
under utils.ts
paste the following -
export const noop = () => {}
Step Two - Styles and Props setup
Our Collapsible
component will have 2 main elements namely Toggle
& Collapsible
. A toggle can be a button, div, on clicking the toggle we will expand / collapse the Collapsible body. Under Collapse.tsx
paste the following -
import * as React from 'react';
const collapseHeaderStyles: React.CSSProperties = {
boxSizing: 'border-box',
border: '2px solid black',
color: '#212121',
fontFamily: 'Helvetica',
padding: '12px',
fontSize: '16px',
cursor: 'pointer',
};
const collapseBodyStyles: React.CSSProperties = {
boxSizing: 'border-box',
border: '2px solid black',
borderTop: 'none',
color: '#212121',
fontFamily: 'Helvetica',
padding: '12px',
fontSize: '16px',
};
export function Collapse() {
return (
<div>
<div style={collapseHeaderStyles}>Toggle</div>
<div
style={{
boxSizing: 'border-box',
}}
>
<div style={collapseBodyStyles}>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptate
dicta, sapiente, praesentium vero sed deleniti ea dolorum hic commodi
dolores quam itaque unde, architecto alias.
</div>
</div>
</div>
);
}
In our App.tsx lets use our collapse component -
import { Collapse } from "./Collapse";
export function App() {
return <Collapse />;
}
Run the project you should be able to see this UI
Step Three - Component Props
type CollapsibleProps = {
/** default state of the collapsible */
isOpened?: boolean;
/** triggered when the component starts closing */
onCollapseStart?: () => void;
/** triggered after the component is closed */
onCollapseEnd?: () => void;
/** triggered when the component starts opening */
onExpandStart?: () => void;
/** triggered after the component is opened */
onExpandEnd?: () => void;
/** triggered on open and close */
onToggle?: () => void;
};
export function Collapse({
isOpened = false,
onCollapseStart = noop,
onCollapseEnd = noop,
onExpandStart = noop,
onExpandEnd = noop,
onToggle = noop,
}: CollapsibleProps) {}
-
isOpened
is the default state of the component whether it should be opened or closed when the page first loads. -
onExpandStart
is triggered when the expand animation starts, component is opening. -
onExpandEnd
is triggered when the expand animation ends, component has opened. -
onCollapseStart
is triggered when the collapse animation starts, component is closing. -
onCollapseEnd
is triggered when the collapse animation ends, component has closed. -
onToggle
is triggered after the component opens and closes, (we will remove it later during the hook refactor).
Step Four - Component State
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
const duration = 266;
const collapsedStyles = {
display: "none",
height: "0px",
overflow: "hidden",
};
export function Collapse({
isOpened = false,
onCollapseStart = noop,
onCollapseEnd = noop,
onExpandStart = noop,
onExpandEnd = noop,
onToggle = noop,
}: CollapsibleProps) {
const [isExpanded, setExpanded] = React.useState(isOpened);
const [collapsibleStyles, setStylesRaw] = React.useState<React.CSSProperties>(
isOpened ? {} : collapsedStyles
);
const uniqueId = React.useId();
const containerRef = React.useRef<HTMLDivElement | null>(null);
return (
<div>
<div
id={`react-collapsed-toggle-${uniqueId}`}
aria-controls={`react-collapsed-panel-${uniqueId}`}
aria-expanded={isExpanded}
tabIndex={0}
style={collapseHeaderStyles}
onClick={() => {
onToggle();
}}
>
{isExpanded ? "Close" : "Open"}
</div>
<div
ref={containerRef}
style={{
boxSizing: "border-box",
...collapsibleStyles,
}}
>
<div
id={`react-collapsed-panel-${uniqueId}`}
aria-hidden={!isExpanded}
style={collapseBodyStyles}
>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptate
dicta, sapiente, praesentium vero sed deleniti ea dolorum hic commodi
dolores quam itaque unde, architecto alias.
</div>
</div>
</div>
);
}
-
isOpened
is the state of theCollapsible
opened or closed. -
collapsibleStyles
will keep a track of the styles of theCollapsible body
height, transition, etc. we are basically changing the styles when we transition from expand to collapse or vice-versa. - Say if we are in closed (isExpanded = false) state the height of the
Collapsible body
is 0px and when we transition to opened (isExpanded = true) state the height will be the height of the container. - Also, we are using a ref, that we have attached to the
Collapsible body
element, to get its height. Now underutils.ts
paste -
export function getElementHeight(
el: RefObject<HTMLElement> | { current?: { scrollHeight: number } }
): string | number {
if (!el?.current) {
return "auto";
}
return el.current.scrollHeight;
}
type AnyFunction = (...args: any[]) => unknown;
export const callAll =
(...fns: AnyFunction[]) =>
(...args: any[]): void =>
fns.forEach((fn) => fn && fn(...args));
Run the project from the terminal and check the output -
Now pass isOpened
prop as true to your Collapse component under App.tsx
and check the output -
export function App() {
return <Collapse isOpened />;
}
Step Four - Logic
Now our next task is, when we click on the Toggle Element
Collapsible Header
in our case, the Collapsible Body
should open and if it is opened it should close. In Collapse.tsx
paste -
import * as React from "react";
import { flushSync } from "react-dom";
import { callAll, getElementHeight, noop } from "./utils";
const collapseHeaderStyles: React.CSSProperties = {
boxSizing: "border-box",
border: "2px solid black",
color: "#212121",
fontFamily: "Helvetica",
padding: "12px",
fontSize: "16px",
cursor: "pointer",
};
const collapseBodyStyles: React.CSSProperties = {
boxSizing: "border-box",
border: "2px solid black",
borderTop: "none",
color: "#212121",
fontFamily: "Helvetica",
padding: "12px",
fontSize: "16px",
};
type CollapsibleProps = {
/** default state of the collapsible */
isOpened?: boolean;
/** triggered when the component starts closing */
onCollapseStart?: () => void;
/** triggered after the component is closed */
onCollapseEnd?: () => void;
/** triggered when the component starts opening */
onExpandStart?: () => void;
/** triggered after the component is opened */
onExpandEnd?: () => void;
/** triggered on open and close */
onToggle?: () => void;
};
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
const duration = 266;
const collapsedStyles = {
display: "none",
height: "0px",
overflow: "hidden",
};
export function Collapse({
isOpened = false,
onCollapseStart = noop,
onCollapseEnd = noop,
onExpandStart = noop,
onExpandEnd = noop,
onToggle = noop,
}: CollapsibleProps) {
const [isExpanded, setExpanded] = React.useState(isOpened);
const [collapsibleStyles, setStylesRaw] = React.useState<React.CSSProperties>(
isOpened ? {} : collapsedStyles
);
const uniqueId = React.useId();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const setStyles = (newStyles: {} | ((oldStyles: {}) => {})) => {
flushSync(() => {
setStylesRaw(newStyles);
});
};
const mergeStyles = (newStyles: {}) => {
setStyles((oldStyles) => ({ ...oldStyles, ...newStyles }));
};
function handleTransitionEnd(e: React.TransitionEvent) {
if (e.target !== containerRef.current || e.propertyName !== "height") {
return;
}
if (isExpanded) {
const height = getElementHeight(containerRef);
// If the height at the end of the transition
// matches the height we're animating to,
if (height === collapsedStyles.height) {
setStyles({});
} else {
// If the heights don't match, this could be due the height
// of the content changing mid-transition
mergeStyles({ height });
}
onExpandEnd();
}
onCollapseEnd();
}
function expandCollapse(nextState: boolean) {
setExpanded(nextState);
if (nextState) {
requestAnimationFrame(() => {
onExpandStart();
mergeStyles({
willChange: "height",
display: "block",
overflow: "hidden",
});
requestAnimationFrame(() => {
const height = getElementHeight(containerRef);
mergeStyles({
transition: `height ${duration}ms ${easing}`,
height,
});
});
});
} else {
requestAnimationFrame(() => {
onCollapseStart();
const height = getElementHeight(containerRef);
mergeStyles({
transition: `height ${duration}ms ${easing}`,
willChange: "height",
height,
});
requestAnimationFrame(() => {
mergeStyles({
height: "0px",
overflow: "hidden",
});
});
});
}
}
return (
<div>
<div
id={`react-collapsed-toggle-${uniqueId}`}
aria-controls={`react-collapsed-panel-${uniqueId}`}
aria-expanded={isExpanded}
tabIndex={0}
style={collapseHeaderStyles}
onTransitionEnd={handleTransitionEnd}
onClick={callAll(() => expandCollapse(!isExpanded), onToggle)}
>
{isExpanded ? "Close" : "Open"}
</div>
<div
ref={containerRef}
style={{
boxSizing: "border-box",
...collapsibleStyles,
}}
>
<div
id={`react-collapsed-panel-${uniqueId}`}
aria-hidden={!isExpanded}
style={collapseBodyStyles}
>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. Voluptate
dicta, sapiente, praesentium vero sed deleniti ea dolorum hic commodi
dolores quam itaque unde, architecto alias.
</div>
</div>
</div>
);
}
- On clicking the collapse header, we will call the
expandCollpase
function and pass in the nextState. - If we are opening the component, we transition from height 0px to the height of the element and vice-versa.
- We use
requestAnimationFrame
for performing fast and performant animations. For more info please check this video. - To know more about flushSync you can check the docs here. Automatic batching in React is indeed an amazing feature. But, there can be situations where we need to prevent this from happening. For that, React provides a method named flushSync() in react-dom that allows us to trigger a re-render for a specific state update.
- We want to read height of the
Collapsible body
so we make sure we do all the changes to the DOM before we read the height. - Now from the terminal run npm run dev and check the collapsible component.
Step Five: Sync with external store
Many a times, you would like to control the Collapsible component
from an outside piece of state. For that to work we will use -
-
useEffect
in our Collapse component to sync external state with our Collapse component state. ThisuseEffect
should only run after our component has mounted. - Pass onToggle prop to our collapse component so that we can sync our internal state with the outside component.
Paste the following in the
Collapsible.tsx
-
const mounted = React.useRef<boolean>(false);
React.useEffect(() => {
if (mounted.current) {
expandCollapse(isOpened);
}
mounted.current = true;
}, [isOpened]);
Paste the following in App.tsx
and check our Collapsible -
import * as React from 'react';
import { Collapse } from './Collapse';
export function App() {
const [isExpanded, setIsExpanded] = React.useState(false);
function toggleCollapse() {
setIsExpanded((prevState) => !prevState);
}
return (
<React.Fragment>
<button onClick={toggleCollapse}>Toggle</button>
<Collapse isOpened={isExpanded} onToggle={toggleCollapse} />
</React.Fragment>
);
}
Step Six: Turn component into a generic hook
From the component code we can see we have 2 main elements in the Collapse component one the toggle
, on clicking it we are calling the expandCollapse function
and the other the collapse body on which we attach our ref, get it's height and spread the styles.
So, if we want to create a hook, we need to basically export the config / props for the toggle
and collapse body
and that is what react collapse does. Along with that we will also export the isExpanded
state and expandCollapse
function so that we can toggle our collapse component using say an external button. We want to achieve something similar to the following -
import React from 'react'
import useCollapse from 'react-collapsed'
function App() {
const { getCollapseProps, getToggleProps, isExpanded } = useCollapse()
return (
<div>
<button {...getToggleProps()}>
{isExpanded ? 'Collapse' : 'Expand'}
</button>
<section {...getCollapseProps()}>Collapsed content</section>
</div>
)
}
-
getToggleProps()
which we spread on the toggle element like theButton or Collapsible header
this function manages theonClick()
for the Toggle element, calls theexpandCollapse function
. -
getCollapseProps()
which we spread on theCollapsible body
so that we can animate its height transition it from closed to open, etc. - Finally, we have the
isExpaned
state of our collapsible. Let's create a new fileuse-collapse.ts
and paste -
import {
useEffect,
useState,
CSSProperties,
MouseEvent,
useRef,
TransitionEvent,
} from "react";
import { flushSync } from "react-dom";
import { callAll, getElementHeight, noop } from "./utils";
const easing = "cubic-bezier(0.4, 0, 0.2, 1)";
const duration = 266;
const collapsedStyles = {
display: "none",
height: "0px",
overflow: "hidden",
};
type UseCollapseInput = {
/** default state of the collapsible */
isOpened?: boolean;
/** triggered when the component is closing */
onCollapseStart?: () => void;
/** triggered after the component is closed */
onCollapseEnd?: () => void;
/** triggered when the component is opening */
onExpandStart?: () => void;
/** triggered after the component is opened */
onExpandEnd?: () => void;
/** triggered on open and close */
onToggle?: () => void;
};
export function useCollapse({
isOpened = false,
onExpandStart = noop,
onExpandEnd = noop,
onCollapseStart = noop,
onCollapseEnd = noop,
}: UseCollapseInput = {}) {
const [isExpanded, setExpanded] = useState(isOpened);
const [collapsibleStyles, setStylesRaw] = useState<CSSProperties>(
isOpened ? {} : collapsedStyles
);
const containerRef = useRef<HTMLElement | null>(null);
const mounted = useRef<boolean>(false);
useEffect(() => {
if (mounted.current) {
expandCollapse(isOpened);
}
mounted.current = true;
}, [isOpened]);
const setStyles = (newStyles: {} | ((oldStyles: {}) => {})) => {
flushSync(() => {
setStylesRaw(newStyles);
});
};
const mergeStyles = (newStyles: {}) => {
setStyles((oldStyles) => ({ ...oldStyles, ...newStyles }));
};
const handleTransitionEnd = (e: React.TransitionEvent): void => {
if (e.target !== containerRef.current || e.propertyName !== "height") {
return;
}
if (isExpanded) {
const height = getElementHeight(containerRef);
// If the height at the end of the transition
// matches the height we're animating to,
if (height === collapsibleStyles.height) {
setStyles({});
} else {
// If the heights don't match, this could be due the height of the content changing mid-transition
mergeStyles({ height });
}
onExpandEnd();
}
onCollapseEnd();
};
function expandCollapse(nextState: boolean) {
setExpanded(nextState);
if (nextState) {
requestAnimationFrame(() => {
onExpandStart();
mergeStyles({
willChange: "height",
display: "block",
overflow: "hidden",
});
requestAnimationFrame(() => {
const height = getElementHeight(containerRef);
mergeStyles({
transition: `height ${duration}ms ${easing}`,
height,
});
});
});
} else {
requestAnimationFrame(() => {
onCollapseStart();
const height = getElementHeight(containerRef);
mergeStyles({
transition: `height ${duration}ms ${easing}`,
willChange: "height",
height,
});
requestAnimationFrame(() => {
mergeStyles({
height: "0px",
overflow: "hidden",
});
});
});
}
}
function getToggleProps({
disabled = false,
onClick = noop,
...rest
}: GetTogglePropsInput = {}): GetTogglePropsOutput {
return {
type: "button",
role: "button",
id: `react-collapsed-toggle`,
"aria-controls": `react-collapsed-panel`,
"aria-expanded": isExpanded,
tabIndex: 0,
disabled,
...rest,
onClick: disabled
? noop
: callAll(() => expandCollapse(!isExpanded), onClick),
};
}
function getCollapseProps({
style = {},
onTransitionEnd = noop,
refKey = "ref",
...rest
}: GetCollapsePropsInput = {}): GetCollapsePropsOutput {
return {
id: `react-collapsed-panel`,
"aria-hidden": !isExpanded,
[refKey]: containerRef,
...rest,
onTransitionEnd: callAll(handleTransitionEnd, onTransitionEnd),
style: {
boxSizing: "border-box",
// additional styles passed, e.g. getCollapseProps({style: {}})
...style,
// style overrides from state
...collapsibleStyles,
},
};
}
return {
getToggleProps,
getCollapseProps,
isExpanded,
toggle: () => expandCollapse(!isExpanded),
};
}
type ButtonType = "submit" | "reset" | "button";
export interface GetTogglePropsOutput {
disabled: boolean;
type: ButtonType;
role: string;
id: string;
"aria-controls": string;
"aria-expanded": boolean;
tabIndex: number;
onClick: (e: MouseEvent) => void;
}
export interface GetTogglePropsInput {
[key: string]: unknown;
disabled?: boolean;
refKey?: string;
onClick?: (e: MouseEvent) => void;
}
export interface GetCollapsePropsOutput {
id: string;
onTransitionEnd: (e: TransitionEvent) => void;
style: CSSProperties;
"aria-hidden": boolean;
}
export interface GetCollapsePropsInput {
[key: string]: unknown;
style?: CSSProperties;
refKey?: string;
onTransitionEnd?: (e: TransitionEvent) => void;
}
-
getToggleProps
function we will be spread on theToggle element
. On clicking the element, theCollapsible element
should open and close. Therefore, we have to callexpandCollapse function
in theonClick
of theToggle element
, this we handle in the hook. - Another thing is that we can also pass an onClick handler to the
getToggleProps
, if want to execute some more code, eg. log the clicks. - Whatever we are handling internally in the hook, an example been the styles of the
Collapsible
we are also giving an option for the user to pass in additional styles if he wants. You can see thegetCollapse function
also takes in optional styles.
Now under App.tsx
paste the following -
import React from "react";
import { useCollapse } from "./use-collapse";
const collapseHeaderStyles: React.CSSProperties = {
boxSizing: "border-box",
border: "2px solid black",
color: "#212121",
fontFamily: "Helvetica",
padding: "12px",
fontSize: "16px",
cursor: "pointer",
};
const collapseBodyStyles: React.CSSProperties = {
boxSizing: "border-box",
border: "2px solid black",
borderTop: "none",
color: "#212121",
fontFamily: "Helvetica",
padding: "12px",
fontSize: "16px",
};
export function App() {
const { getCollapseProps, getToggleProps, isExpanded } = useCollapse();
return (
<div>
<div style={collapseHeaderStyles} {...getToggleProps({})}>
{isExpanded ? "Close" : "Open"}
</div>
<div {...getCollapseProps()}>
<div style={collapseBodyStyles}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis
impedit, quibusdam sapiente voluptatum doloremque laudantium, vel
porro incidunt quidem dolores quis nostrum laboriosam suscipit ut vero
molestias obcaecati accusantium culpa reiciendis optio. Autem, vero
ex.
</div>
</div>
</div>
);
}
Toggle the Collapsible using an External Button -
export function App() {
const { getCollapseProps, getToggleProps, isExpanded, toggle } =
useCollapse();
return (
<div>
<button onClick={toggle}>
{isExpanded ? "Close Collapsible" : "Open Collapsible"}
</button>
<div style={collapseHeaderStyles} {...getToggleProps({})}>
{isExpanded ? "Close" : "Open"}
</div>
<div {...getCollapseProps()}>
<div style={collapseBodyStyles}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis
impedit, quibusdam sapiente voluptatum doloremque laudantium, vel
porro incidunt quidem dolores quis nostrum laboriosam suscipit ut vero
molestias obcaecati accusantium culpa reiciendis optio. Autem, vero
ex.
</div>
</div>
</div>
);
}
Sometimes we might need to control the Collapsible component using an external state altogether for such uses cases -
export function App() {
const [isExpanded, setIsExpanded] = React.useState(false);
const { getCollapseProps, getToggleProps } = useCollapse({
isOpened: isExpanded,
});
function toggleCollapsible() {
setIsExpanded((prevState) => !prevState);
}
return (
<div>
<button onClick={toggleCollapsible}>
{isExpanded ? "Close Collapsible" : "Open Collapsible"}
</button>
<div
style={collapseHeaderStyles}
{...getToggleProps({
onClick: toggleCollapsible,
})}
>
{isExpanded ? "Close" : "Open"}
</div>
<div {...getCollapseProps()}>
<div style={collapseBodyStyles}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis
impedit, quibusdam sapiente voluptatum doloremque laudantium, vel
porro incidunt quidem dolores quis nostrum laboriosam suscipit ut vero
molestias obcaecati accusantium culpa reiciendis optio. Autem, vero
ex.
</div>
</div>
</div>
);
}
- Take a note that we have to pass the
onClick
function to thegetToggleProps
. When we click on theToggle
it will change the state of the hook (internal state) to saytrue
using theexpandCollapse
function this we are managing internally in the hook. - But the App component's state is still
false
. Therefore, we passed anonClick
handler togetToggleProps
in order to sync the hook's internal state with App component's state.
Nested Collapsible Example -
function Nested() {
const { isExpanded, getCollapseProps, getToggleProps } = useCollapse({
isOpened: true,
});
return (
<div>
<div style={collapseHeaderStyles} {...getToggleProps()}>
{isExpanded ? "Close" : "Open"}
</div>
<div {...getCollapseProps()}>
<div style={collapseBodyStyles}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis
impedit, quibusdam sapiente voluptatum doloremque laudantium, vel
porro incidunt quidem dolores quis nostrum laboriosam suscipit ut vero
molestias obcaecati accusantium culpa reiciendis optio. Autem, vero
ex.
</div>
</div>
</div>
);
}
export function App() {
const { isExpanded, getCollapseProps, getToggleProps } = useCollapse({
isOpened: true,
});
return (
<div>
<div style={collapseHeaderStyles} {...getToggleProps()}>
{isExpanded ? "Close" : "Open"}
</div>
<div {...getCollapseProps()}>
<div style={collapseBodyStyles}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Omnis
impedit, quibusdam sapiente voluptatum doloremque laudantium, vel
porro incidunt quidem dolores quis nostrum laboriosam suscipit ut vero
molestias obcaecati accusantium culpa reiciendis optio. Autem, vero
ex.
<Nested />
</div>
</div>
</div>
);
}
- The code that we have under
handleTransitionEnd
inside of the hook, fixes some nested collapsible issues.
Summary
In this tutorial we first created a Collapsible
component and then refactored it into a generic hook. Let me know if you have any questions / suggestions. Special shout out to the maintainers and collaborators of react-collapsed library. Until next time PEACE.
Top comments (0)