I don't currently use any external UI libraries in my React app, so when the designs called for an accordion component, I decided to figure out how easy it would be to build one from scratch. Turns out - it ain't too bad. 😄
Building blocks
The basic building blocks you'll need to build up the accordion are:
- Some kind of chevron icon (I used an SVG)
- State variables for:
- Whether the accordion is active (open) or not active (closed).
- Depending on the active state, what the height of the entire accordion should be.
- The rotation angle of the chevron icon as the accordion transitions from an open to closed state (and vice versa).
The two props I'd like to pass into my Accordion component are a title
(the text that's seen when the accordion is closed) and content
(the additional text that's seen when the accordion is open).
If you're not familiar with the useState
React hook, the values in the parentheses are the initial values for the state variable, so for e.g. const active
's starting value is false
(closed). The transform duration-700 ease
refers to TailwindCSS utility classes (these classes basically sets the scene, telling the component that at some point, we're going to want to animate something).
import React, { useState } from 'react'
interface AccordionProps {
title: React.ReactNode
content: React.ReactNode
}
export const Accordion: React.FC<AccordionProps> = ({ title, content }) => {
const [active, setActive] = useState(false)
const [height, setHeight] = useState('0px')
const [rotate, setRotate] = useState('transform duration-700 ease')
// ...
}
Layer 1
Next layer up is having some kind of toggle function that sets the active state to either true or false. This function should also set the height and rotation depending on what the active state is.
Note that we've yet to determine the height when our active
state is true
. That comes in the next layer below.
import React, { useState } from 'react'
interface AccordionProps {
title: React.ReactNode
content: React.ReactNode
}
export const Accordion: React.FC<AccordionProps> = ({ title, content }) => {
const [active, setActive] = useState(false)
const [height, setHeight] = useState('0px')
const [rotate, setRotate] = useState('transform duration-700 ease')
function toggleAccordion() {
setActive(active === false ? true : false)
// @ts-ignore
setHeight(active ? '0px' : `${someHeightYetToBeDetermined}px`)
setRotate(active ? 'transform duration-700 ease' : 'transform duration-700 ease rotate-180')
}
// ...
}
Layer 2
Going another layer up, we need some way of targeting the DOM, where the inner content of the accordion will reside. An easy way of doing this is through the helpful useRef
hook that React gives us, that allows us to specifically target (in my case) a <div>
where my content
will sit.
To make this work, I used inline CSS to set a maxHeight
attribute which equates to the height
state variable I introduced in Layer 1 above. i.e. if it's not active, the height will be 0
(hidden). We can now also refer to the contentSpace
to determine what the height should be when the accordion is active, using ${contentSpace.current.scrollHeight}px
.
Note also that I wanted a nice opening and closing animation effect, so I used TailwindCSS to set an ease-in-out
effect.
import React, { useRef, useState } from 'react'
import { appConfig } from '../../../../appConfig'
interface AccordionProps {
title: React.ReactNode
content: React.ReactNode
}
export const Accordion: React.FC<AccordionProps> = ({ title, content }) => {
// ...
const contentSpace = useRef<HTMLDivElement>(null)
function toggleAccordion() {
setActive(active === false ? true : false)
// @ts-ignore
setHeight(active ? '0px' : `${contentSpace.current.scrollHeight}px`)
setRotate(active ? 'transform duration-700 ease' : 'transform duration-700 ease rotate-180')
}
return (
<div
ref={contentSpace}
style={{ maxHeight: `${height}` }}
className="overflow-auto transition-max-height duration-700 ease-in-out"
>
<div className="pb-10">{content}</div>
</div>
)
}
Putting it all together
All that's left now is to pull all of our building blocks together. Here's what our complete Accordion component looks like.
The main things to note here are:
- That I created a button, within which the
title
prop sits along with my chevron icon. - I added an
onClick
handler to this button which I hooked up to thetoggleAccordion
function we created in Level 1. - I added the
rotate
state variable to theclassNames
for my chevron icon. These are the Tailwind classes that rotates the icon depending on theactive
state of the accordion.
import React, { useRef, useState } from 'react'
import { appConfig } from '../../../../appConfig'
interface AccordionProps {
title: React.ReactNode
content: React.ReactNode
}
export const Accordion: React.FC<AccordionProps> = ({ title, content }) => {
const [active, setActive] = useState(false)
const [height, setHeight] = useState('0px')
const [rotate, setRotate] = useState('transform duration-700 ease')
const contentSpace = useRef(null)
function toggleAccordion() {
setActive(active === false ? true : false)
// @ts-ignore
setHeight(active ? '0px' : `${contentSpace.current.scrollHeight}px`)
setRotate(active ? 'transform duration-700 ease' : 'transform duration-700 ease rotate-180')
}
return (
<div className="flex flex-col">
<button
className="py-6 box-border appearance-none cursor-pointer focus:outline-none flex items-center justify-between"
onClick={toggleAccordion}
>
<p className="inline-block text-footnote light">{title}</p>
<img
src={`${appConfig.publicUrl}/img/icons/chevron-up.svg`}
alt="Chevron icon"
className={`${rotate} inline-block`}
/>
</button>
<div
ref={contentSpace}
style={{ maxHeight: `${height}` }}
className="overflow-auto transition-max-height duration-700 ease-in-out"
>
<div className="pb-10">{content}</div>
</div>
</div>
)
}
And that's it! What did you think? Any ways I can improve this? Let's chat on Twitter @bionicjulia or Instagram @bionicjulia.
Top comments (6)
yeah! I used your article as a roadmap to implement an accordion on my React App menu! Thanks Julia!
A tiny suggestion (and as a thank you note)
setActive(active === false ? true : false)
to
setActive( !active )
Keep on coding in the free world!
Thinking about it more, perhaps this is even better to ensure it's fail-safe! :)
setActive((prevState) => !prevState)
I'm so glad you found this useful. Ah, that's a great spot - thank you! :)
HI,
thank you for sharing good component.
I think it is better to not have scrollbar when ease-in-out.
How about following code? (added
overflow-y-hidden
in className)Thanks!
Yes, that's a very good point! Thank you for the improvement suggestion. :)
Nice work.
Instead of
// @ts-ignore
, set type on useRef and use optional chaining.