- Published on
Building an accordion component from scratch for your next UI component library
Overview
The following content and code is meant to be a companion to the video Build an accordion component for your UI library from scratch with React, TypeScript, and Tailwind.
- Setting up the accordion template
- Adding the expand/collapse functionality
- "AccordionItem" component
- "Accordion" component
- "AccordionTrigger" and "AccordionContent" components
- Adding animation
- Adding a "defaultOpen" prop
Setting up the accordion template
First thing we want to do is set up the initial template for our accordion. We'll start with a simple static template and then we'll add the dynamic functionality to it.
- <p className="text-4xl text-black dark:text-white">¯\_(ツ)_/¯</p>
+ <div className="rounded-md border-2 border-slate-200 p-4 dark:border-slate-800 sm:p-10">
+ {/* Outer Accordion wrapepr */}
+ <div className="w-full">
+ {/* Accordion Item */}
+ <div className="border-b-2 border-b-slate-200 bg-white dark:border-b-slate-800 dark:bg-slate-950">
+ {/* Accordion Trigger */}
+ <button className="w-full px-4 py-2 text-left text-lg font-bold text-slate-950 hover:underline dark:text-white">
+ Why should you 👍 the video?
+ </button>
+ {/* Accordion Content */}
+ <div className="overflow-hidden">
+ <div className="bg-slate-200 p-4 text-slate-950 dark:bg-slate-800 dark:text-slate-400">
+ It shows you care and want to see more 😃
+ </div>
+ </div>
+ </div>
{/* ...more accordion items below... */}
+ </div>
+ </div>
Adding the expand/collapse functionality
Now that we have our static template, we can add the dynamic functionality to it. We'll start by adding the state and event handlers to the App
component, and modify the trigger and content accordingly.
const App = () => {
+ // State hook to manage the open item.
+ const [openItem, setOpenItem] = useState<string | null>(null)
+
+ // Function to handle item click events.
+ const handleItemClick = (value: string) => {
+ setOpenItem(openItem === value ? null : value)
+ }
+
return (
{/* ... */}
{/* Accordion Trigger */}
- <button className="w-full px-4 py-2 text-left text-lg font-bold text-slate-950 hover:underline dark:text-white">
+ <button
+ onClick={() => handleItemClick("item-1")} // Event handler for button click.
+ className="w-full px-4 py-2 text-left text-lg font-bold text-slate-950 hover:underline dark:text-white"
+ >
Why should you 👍 the video?
</button>
{/* Accordion Content */}
- <div className="overflow-hidden">
+ <div
+ className="overflow-hidden"
+ style={{ maxHeight: openItem === "item-1" ? "none" : 0 }}
+ >
<div className="bg-slate-200 p-4 text-slate-950 dark:bg-slate-800 dark:text-slate-400">
It shows you care and want to see more 😃
</div>
</div>
{/* ...repeat for rest of items... */}
)
}
"AccordionItem" component
Now that we have the accordion working, we can extract the accordion item into its own component. This will allow us to reuse the accordion item in other places in our application.
+type AccordionItemProps = {
+ triggerText: string // Text for the accordion trigger.
+ isOpen?: boolean // Optional boolean to track if item is open.
+ onToggle?: () => void // Optional function for toggle behavior.
+ children: React.ReactNode // Children elements.
+}
+
+export const AccordionItem: React.FC<AccordionItemProps> = ({
+ triggerText,
+ isOpen = false,
+ onToggle,
+ children,
+}) => {
+ // State for managing the maximum height of the content area.
+ const [maxHeight, setMaxHeight] = useState<string>(isOpen ? "none" : "0")
+
+ useEffect(() => {
+ if (isOpen) {
+ setMaxHeight("none")
+ } else {
+ setMaxHeight("0")
+ }
+ }, [isOpen])
+
+ return (
+ <div className="border-b-2 border-b-slate-200 bg-white dark:border-b-slate-800 dark:bg-slate-950">
+ {/* Accordion Trigger */}
+ <button
+ onClick={onToggle} // Event handler for button click.
+ className="w-full px-4 py-2 text-left text-lg font-bold text-slate-950 hover:underline dark:text-white"
+ >
+ {triggerText}
+ </button>
+ {/* Accordion Content */}
+ <div
+ className="overflow-hidden"
+ style={{ maxHeight }}
+ >
+ <div className="bg-slate-200 p-4 text-slate-950 dark:bg-slate-800 dark:text-slate-400">
+ {children}
+ </div>
+ </div>
+ </div>
+ )
+}
+AccordionItem.displayName = "AccordionItem"
Import the new AccordionItem to App.tsx and update the JSX accordingly:
{/* ... */}
- {/* Accordion Item */}
- <div className="border-b-2 border-b-slate-200 bg-white dark:border-b-slate-800 dark:bg-slate-950">
- {/* Accordion Trigger */}
- <button
- onClick={() => handleItemClick("item-1")} // Event handler for button click.
- className="w-full px-4 py-2 text-left text-lg font-bold text-slate-950 hover:underline dark:text-white"
- >
- Why should you 👍 the video?
- </button>
- {/* Accordion Content */}
- <div
- className="overflow-hidden"
- style={{ maxHeight: openItem === "item-1" ? "none" : 0 }}
- >
- <div className="bg-slate-200 p-4 text-slate-950 dark:bg-slate-800 dark:text-slate-400">
- It shows you care and want to see more 😃
- </div>
- </div>
- </div>
+ {/* Accordion Items */}
+ <AccordionItem
+ triggerText="Why should you 👍 the video?"
+ isOpen={openItem === "item-1"}
+ onToggle={() => handleItemClick("item-1")}
+ >
+ It shows you care and want to see more 😃
+ </AccordionItem>
{/* ...rest of accordion accordion items... */}
"Accordion" component
Now that we have the accordion item extracted into its own component, we can extract the accordion into its own component. This will enable us to create an internal state at the Accordion component to manage the children's open state and toggle behavior without requiring consumers to write their own state management logic.
+type AccordionProps = {
+ children: ReactElement[]
+ className?: string
+}
+
+export const Accordion: React.FC<AccordionProps> = ({
+ children,
+ className,
+}) => {
+ // State hook to manage the open item.
+ const [openItem, setOpenItem] = useState<string | null>(null)
+
+ // Function to handle item click events.
+ const handleItemClick = (value: string) => {
+ setOpenItem(openItem === value ? null : value)
+ }
+
+ return (
+ <div className={className}>
+ {/* Mapping over the children and cloning each element to pass additional props. */}
+ {React.Children.map(children, (child) =>
+ cloneElement(child, {
+ isOpen: child.props.value === openItem,
+ onToggle: () => handleItemClick(child.props.value),
+ }),
+ )}
+ </div>
+ )
+}
+Accordion.displayName = "Accordion"
type AccordionItemProps = {
+ value: string // String identifier for the accordion item.
triggerText: string // Text for the accordion trigger.
isOpen?: boolean // Optional boolean to track if item is open.
onToggle?: () => void // Optional function for toggle behavior.
children: React.ReactNode // Children elements.
}
const App = () => {
- // State hook to manage the open item.
- const [openItem, setOpenItem] = useState<string | null>(null)
-
- // Function to handle item click events.
- const handleItemClick = (value: string) => {
- setOpenItem(openItem === value ? null : value)
- }
-
return (
<Layout>
<div className="rounded-md border-2 border-slate-200 p-4 dark:border-slate-800 sm:p-10">
- {/* Outer Accordion wrapepr */}
- <div className="w-full">
- {/* Accordion Items */}
+ <Accordion className="w-full">
<AccordionItem
+ value="item-1"
triggerText="Why should you 👍 the video?"
- isOpen={openItem === "item-1"}
- onToggle={() => handleItemClick("item-1")}
>
It shows you care and want to see more 😃
</AccordionItem>
<AccordionItem
+ value="item-2"
triggerText="What's the point of subscribing and clicking the 🔔?"
- isOpen={openItem === "item-2"}
- onToggle={() => handleItemClick("item-2")}
>
You get notified of new videos and it motivates me to make more free
educational content 😄
</AccordionItem>
<AccordionItem
+ value="item-3"
triggerText="What is 'watch time'? 🤔⏱️"
- isOpen={openItem === "item-3"}
- onToggle={() => handleItemClick("item-3")}
>
It let's YouTube know that people are enjoying the video and YouTube
will show it to more people 🤩
</AccordionItem>
- </div>
+ </Accordion>
"AccordionTrigger" and "AccordionContent" components
Create reusable "AccordionTrigger" and "AccordionContent" components for better modularity, reusability, and readability. This also allows for cleaner code when consuming the Accordion component API and allows for a stronger seperatoion of concerns between the trigger and the content. This will also allow for more flexibility in terms of customization and styling.
type AccordionItemProps = {
value: string // String identifier for the accordion item.
- triggerText: string // Text for the accordion trigger.
isOpen?: boolean // Optional boolean to track if item is open.
onToggle?: () => void // Optional function for toggle behavior.
children: React.ReactNode // Children elements.
}
export const AccordionItem: React.FC<AccordionItemProps> = ({
- triggerText,
+ children,
isOpen = false,
onToggle,
}) => {
return (
<div className="border-b-2 border-b-slate-200 bg-white dark:border-b-slate-800 dark:bg-slate-950">
+ {/* Mapping over the children and cloning each element to pass additional props. */}
+ {React.Children.map(children, (child) =>
+ React.cloneElement(child as React.ReactElement<any>, {
+ isOpen,
+ onToggle,
+ }),
+ )}
</div>
)
}
// ...
+type AccordionTriggerProps = {
+ children: React.ReactNode // Children elements.
+ onToggle?: () => void // Optional function for toggle behavior.
+}
+
+export const AccordionTrigger: React.FC<AccordionTriggerProps> = ({
+ children,
+ onToggle,
+}) => {
+ return (
+ <button
+ className="w-full px-4 py-2 text-left text-lg font-bold text-slate-950 hover:underline dark:text-white"
+ onClick={onToggle} // Event handler for button click.
+ >
+ {children}
+ </button>
+ )
+}
+AccordionTrigger.displayName = "AccordionTrigger"
+type AccordionContentProps = {
+ children: ReactNode // Children elements.
+ isOpen?: boolean // Optional boolean to track if content is open.
+}
+export const AccordionContent: React.FC<AccordionContentProps> = ({
+ children,
+ isOpen,
+}) => {
+ // State for managing the maximum height of the content area.
+ const [maxHeight, setMaxHeight] = useState<string>(isOpen ? "none" : "0")
+
+ useEffect(() => {
+ if (isOpen) {
+ setMaxHeight("none")
+ } else {
+ setMaxHeight("0")
+ }
+ }, [isOpen])
+
+ return (
+ <div className="border-b-2 border-b-slate-200 bg-white dark:border-b-slate-800 dark:bg-slate-950">
+ {/* Accordion Trigger */}
+ <button
+ onClick={onToggle} // Event handler for button click.
+ className="w-full px-4 py-2 text-left text-lg font-bold text-slate-950 hover:underline dark:text-white"
+ >
+ {triggerText}
+ </button>
+ {/* Accordion Content */}
+ <div
+ className="overflow-hidden"
+ style={{ maxHeight }}
+ >
+ <div className="bg-slate-200 p-4 text-slate-950 dark:bg-slate-800 dark:text-slate-400">
+ {children}
+ </div>
+ <div
+ className="overflow-hidden"
+ style={{ maxHeight }}
+ >
+ <div className="bg-slate-200 p-4 text-slate-950 dark:bg-slate-800 dark:text-slate-400">
+ {children}
+ </div>
+ </div>
+ )
+}
+AccordionContent.displayName = "AccordionContent"
// ...
- <AccordionItem
- value="item-1"
- triggerText="Why should you 👍 the video?"
- >
- It shows you care and want to see more 😃
+ <AccordionItem value="item-1">
+ <AccordionTrigger>Why should you 👍 the video?</AccordionTrigger>
+ <AccordionContent>
+ It shows you care and want to see more 😃
+ </AccordionContent>
</AccordionItem>
- <AccordionItem
- value="item-2"
- triggerText="What's the point of subscribing and clicking the 🔔?"
- >
- You get notified of new videos and it motivates me to make more free
- educational content 😄
+ <AccordionItem value="item-2">
+ <AccordionTrigger>
+ What's the point of subscribing and clicking the 🔔?
+ </AccordionTrigger>
+ <AccordionContent>
+ You get notified of new videos and it motivates me to make more
+ free educational content 😄
+ </AccordionContent>
</AccordionItem>
- <AccordionItem
- value="item-3"
- triggerText="What is 'watch time'? 🤔⏱️"
- >
- It let's YouTube know that people are enjoying the video and YouTube
- will show it to more people 🤩
+ <AccordionItem value="item-3">
+ <AccordionTrigger>What is "watch time"? 🤔⏱️</AccordionTrigger>
+ <AccordionContent>
+ It let's YouTube know that people are enjoying the video and
+ YouTube will show it to more people 🤩
+ </AccordionContent>
</AccordionItem>
// ...
Adding animation
Add animation to the accordion to make it more visually appealing and to provide visual feedback to the user when the accordion is expanding and collapsing. We'll also expose an "animated" prop on the Accordion component to enable animation, and update children to use the prop.
type AccordionProps = {
children: ReactElement[]
className?: string
+ animated?: boolean
}
export const Accordion: React.FC<AccordionProps> = ({
children,
className,
+ animated = true,
}) => {
// ...
return (
<div className={className}>
{/* Mapping over the children and cloning each element to pass additional props. */}
{React.Children.map(children, (child) =>
cloneElement(child, {
isOpen: child.props.value === openItem,
onToggle: () => handleItemClick(child.props.value),
+ animated,
}),
)}
</div>
)
}
Accordion.displayName = "Accordion"
type AccordionItemProps = {
value: string // String identifier for the accordion item.
isOpen?: boolean // Optional boolean to track if item is open.
onToggle?: () => void // Optional function for toggle behavior.
children: React.ReactNode // Children elements.
+ animated?: boolean // Optional boolean for animation.
}
export const AccordionItem: React.FC<AccordionItemProps> = ({
children,
isOpen = false,
onToggle,
+ animated,
}) => {
return (
<div className="border-b-2 border-b-slate-200 bg-white dark:border-b-slate-800 dark:bg-slate-950">
{/* Mapping over the children and cloning each element to pass additional props. */}
{React.Children.map(children, (child) =>
React.cloneElement(child as React.ReactElement<any>, {
isOpen,
onToggle,
+ animated,
}),
)}
</div>
)
}
// ...
type AccordionContentProps = {
children: ReactNode // Children elements.
isOpen?: boolean // Optional boolean to track if content is open.
+ animated?: boolean // Optional boolean for animation.
}
export const AccordionContent: React.FC<AccordionContentProps> = ({
children,
isOpen,
+ animated,
}) => {
+ const contentRef = useRef<HTMLDivElement>(null) // Ref to the content div.
// ...
useEffect(() => {
if (isOpen) {
- setMaxHeight("none")
+ setMaxHeight(`${contentRef.current?.scrollHeight ?? 0}px`)
} else {
setMaxHeight("0")
}
}, [isOpen])
return (
<div
- className="overflow-hidden"
+ ref={contentRef}
+ className={`overflow-hidden ${
+ animated ? "transition-[max-height] duration-300 ease-in-out" : ""
+ }`}
style={{ maxHeight }}
>
<div className="bg-slate-200 p-4 text-slate-950 dark:bg-slate-800 dark:text-slate-400">
{children}
</div>
</div>
)
}
// ...
Adding a "defaultOpen" prop
Add a "defaultOpen" prop to the Accordion component to allow consumers to specify which accordion items should be open by default. This will allow consumers to control the initial state of the accordion items.
type AccordionProps = {
children: ReactElement[]
className?: string
animated?: boolean
+ defaultOpenItem?: string
}
export const Accordion: React.FC<AccordionProps> = ({
children,
className,
animated = true,
+ defaultOpenItem,
}) => {
// State hook to manage the open item.
- const [openItem, setOpenItem] = useState<string | null>(null)
+ const [openItem, setOpenItem] = useState<string | null>(
+ defaultOpenItem || null,
+ )