DEV Community

Bionic Julia
Bionic Julia

Posted on • Originally published at bionicjulia.com on

Creating an Accordion Component in React with Typescript and TailwindCSS

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')

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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')
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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 the toggleAccordion function we created in Level 1.
  • I added the rotate state variable to the classNames for my chevron icon. These are the Tailwind classes that rotates the icon depending on the active 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

And that's it! What did you think? Any ways I can improve this? Let's chat on Twitter @bionicjulia or Instagram @bionicjulia.

Latest comments (6)

Collapse
 
chp profile image
r2-d3 • Edited

Nice work.

Instead of // @ts-ignore, set type on useRef and use optional chaining.

const contentSpace = useRef<HTMLDivElement>(null);
.
.
.
setHeight(active ? '0px' : `${contentSpace.current?.scrollHeight || 0}px`);
Enter fullscreen mode Exit fullscreen mode
Collapse
 
brunowolf profile image
They Call Me Wolf

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!

Collapse
 
bionicjulia profile image
Bionic Julia

Thinking about it more, perhaps this is even better to ensure it's fail-safe! :)

setActive((prevState) => !prevState)

Collapse
 
bionicjulia profile image
Bionic Julia

I'm so glad you found this useful. Ah, that's a great spot - thank you! :)

Collapse
 
ysuzuki19 profile image
ysuzuki19

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)

      <div
        ref={contentSpace}
        style={{ maxHeight: `${height}` }}
        className="overflow-auto overflow-y-hidden transition-max-height duration-700 ease-in-out"
      >
        <div className="pb-10">{content}</div>
      </div>
Enter fullscreen mode Exit fullscreen mode

Thanks!

Collapse
 
bionicjulia profile image
Bionic Julia

Yes, that's a very good point! Thank you for the improvement suggestion. :)