Accordion menus are a useful UI pattern for expanding or collapsing content, enhancing the user experience. This article provides a step-by-step guide to creating a customizable accordion component in React. TypeScript is used for type safety, and styled-components are used for styling.
Folder Structure
To maintain a systematic codebase, the following folder structure is used:
[Accordion]
- [ActionBox]
- ActionBox.tsx
- [Details]
- Details.tsx
- [Summary]
- Summary.tsx
- Accordion.tsx
- Accordion.types.ts
- AccordionContext.ts
Type Definitions: `Accordion.types.ts`
First, define the types for the accordion component to ensure type safety and improve code readability.
import React from 'react';
export interface AccordionCommonType {
children?: React.ReactNode | React.ReactNode[];
className?: string;
}
export interface AccordionProps extends AccordionCommonType {
defaultExpanded?: boolean;
expanded?: boolean;
onChange?: (expanded: boolean) => void;
disabled?: boolean;
}
export interface AccordionSummaryProps extends AccordionCommonType {
actionIcon?: React.ReactElement;
onClick?: () => void;
hideActionIcon?: boolean;
}
export type AccordionDetailsProps = AccordionCommonType;
export type AccordionDetailRef = {
readjustHeight: () => void;
}
export interface AccordionActionBoxProps extends AccordionCommonType {
isIconButton?: boolean;
render?: (expanded: boolean) => React.ReactElement;
}
Context: `AccordionContext.ts`
Create a context to manage the state and behavior of the accordion component.
import React, { useContext } from 'react';
import { AccordionProps } from './Accordion.types';
interface AccordionContextType extends Pick<AccordionProps, 'expanded' | 'defaultExpanded' | 'disabled'> {
onToggle: () => void;
}
const AccordionContext = React.createContext<AccordionContextType>({
onToggle: () => {},
});
export const useAccordionContext = () => useContext(AccordionContext);
export default AccordionContext;
Action Box Component: `ActionBox.tsx`
This component renders the action icon for toggling the accordion.
import React, { ReactElement, isValidElement } from 'react';
import { AccordionActionBoxProps } from '../Accordion.types';
import { useAccordionContext } from '../AccordionContext';
import IconButton from '@/components/Button/IconButton';
import { Icon } from '@/components/Icon';
const ActionBox = ({
children,
className,
isIconButton = true,
render: renderFn,
}: AccordionActionBoxProps) => {
const { onToggle, expanded = false, disabled } = useAccordionContext();
const renderElement = children && isValidElement(children) ? (
React.cloneElement(children as ReactElement, {
style: {
transform: `rotate(${expanded ? '180' : '0'}deg)`,
transition: 'transform 0.15s',
}
})
) : (
<Icon
name="arrowLeft"
style={{
transform: `rotate(${expanded ? '90' : '-90'}deg)`,
transition: `transform 0.15s`,
}}
/>
);
if (renderFn) return renderFn(expanded);
return isIconButton ? (
<IconButton
size={32}
onClick={(event) => {
event.stopPropagation();
if (disabled) return;
onToggle();
}}
className={className}
color="white"
>
{renderElement}
</IconButton>
) : (
renderElement
);
};
export default ActionBox;
Details Component: `Details.tsx`
This component handles the expandable content of the accordion.
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import styled from 'styled-components';
import { AccordionDetailsProps, AccordionDetailRef } from '../Accordion.types';
import { useAccordionContext } from '../AccordionContext';
const Details = forwardRef<AccordionDetailRef, AccordionDetailsProps>(
function Details({ children, className }, ref) {
const { expanded } = useAccordionContext();
const innerRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<string>('');
useEffect(() => {
if (innerRef.current) {
setHeight(`${innerRef.current.clientHeight}px`);
}
}, [innerRef.current?.clientHeight, children]);
useImperativeHandle(ref, () => ({
readjustHeight: () => {
setHeight('');
setTimeout(() => {
setHeight(`${innerRef.current?.clientHeight}px`);
}, 50);
}
}));
return (
<DetailContainer
height={height}
expanded={expanded}
className={className}
>
<div ref={innerRef}>{children}</div>
</DetailContainer>
);
}
);
export default Details;
type DetailContainerProps = {
expanded?: boolean;
height: string;
}
const DetailContainer = styled('div')<DetailContainerProps>`
width: 100%;
height: ${({ expanded, height }) => (expanded ? height : 0)};
transition: height 0.15s;
transition-delay: 0.05s;
`;
Summary Component: `Summary.tsx`
This component displays the summary part of the accordion along with the action icon.
import styled from 'styled-components';
import { AccordionSummaryProps } from '../Accordion.types';
import { useAccordionContext } from '../AccordionContext';
import ActionBox from '../ActionBox';
import { Flex } from '@/components/Flex';
const Summary = ({
children,
actionIcon,
onClick,
hideActionIcon,
className,
}: AccordionSummaryProps) => {
const { onToggle, disabled } = useAccordionContext();
return (
<SummaryContainer
className={className}
justify="space-between"
align="center"
onClick={() => {
onClick?.();
if (disabled) return;
onToggle();
}}
boxFill
>
{children}
{!hideActionIcon && (
<SummaryActionIconBox>
{actionIcon ? <ActionBox>{actionIcon}</ActionBox> : <ActionBox />}
</SummaryActionIconBox>
)}
</SummaryContainer>
);
};
export default Summary;
const SummaryContainer = styled(Flex)`
cursor: pointer;
`;
const SummaryActionIconBox = styled('div')`
display: flex;
`;
Accordion Component: `Accordion.tsx`
Finally, integrate everything to complete the accordion component.
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { AccordionProps } from './Accordion.types';
import AccordionContext from './AccordionContext';
import ActionBox from './ActionBox';
import Details from './Details';
import Summary from './Summary';
const Accordion = ({
children,
defaultExpanded = false,
expanded: expandedProps = defaultExpanded,
className,
onChange,
disabled,
}: AccordionProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(defaultExpanded);
const onToggle = () => {
setExpanded((prev) => !prev);
};
const contextValue = React.useMemo(
() => ({ expanded, defaultExpanded, onToggle, disabled }),
[expanded, defaultExpanded, disabled],
);
useEffect(() => {
setExpanded(expandedProps);
}, [expandedProps]);
useEffect(() => {
onChange?.(expanded);
const containerEl = containerRef.current;
if (!containerEl) return;
const timer = setTimeout(() => {
containerEl.style.overflow = 'initial';
}, 200);
if (!expanded) {
clearTimeout(timer);
containerEl.style.overflow = 'hidden';
}
return () => clearTimeout(timer);
}, [expanded, containerRef.current]);
return (
<AccordionContainer className={className} ref={containerRef}>
<AccordionContext.Provider value={contextValue}>
{children}
</AccordionContext.Provider
>
</AccordionContainer>
);
}
Accordion.Summary = Summary;
Accordion.Details = Details;
Accordion.ActionBox = ActionBox;
export default Accordion;
const AccordionContainer = styled('div')`
width: 100%;
overflow: hidden;
align-items: start;
`;