Creating a Custom Accordion Menu Component with React, TypeScript, and Styled-Components

0

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;
`;

Leave a Reply