Accordion

Categorystyled
ArchitectureReact Client Component
Dependencies

Usage

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quibusdam similique ducimus ipsa nostrum neque natus iusto, ea deleniti, dicta minima eos mollitia eius omnis totam esse. Perferendis cum impedit recusandae.

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quibusdam similique ducimus ipsa nostrum neque natus iusto, ea deleniti, dicta minima eos mollitia eius omnis totam esse. Perferendis cum impedit recusandae.

Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quibusdam similique ducimus ipsa nostrum neque natus iusto, ea deleniti, dicta minima eos mollitia eius omnis totam esse. Perferendis cum impedit recusandae.

import { useState } from "react";

import { DemoWrapper } from "./demoWrapper";

import { Accordion } from "@/components/react/styled/accordion";
import { Chip } from "@/components/react/styled/chip";

import {
  CheckBadgeIcon,
  ChevronDoubleDownIcon,
  ClockIcon,
  ExclamationCircleIcon,
} from "@heroicons/react/24/solid";

const data = [
  {
    id: "tos",
    title: "Accepting Terms of service",
    icon: (
      <Chip
        capitalize={false}
        color="successful"
        component="a"
        className="py-1"
      >
        <span className="flex gap-2 items-center content-center">
          <span>Completed</span>
          <CheckBadgeIcon className="w-4 h-4" />
        </span>
      </Chip>
    ),
    content: (
      <p>
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quibusdam
        similique ducimus ipsa nostrum neque natus iusto, ea deleniti, dicta
        minima eos mollitia eius omnis totam esse. Perferendis cum impedit
        recusandae.
      </p>
    ),
  },
  {
    id: "verifying",
    title: "Verifying your documents",
    icon: (
      <Chip capitalize={false} color="warning" component="a" className="py-1">
        <span className="flex gap-2 items-center content-center">
          <span>Waiting for verification</span>
          <ClockIcon className="w-4 h-4" />
        </span>
      </Chip>
    ),
    content: (
      <p>
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quibusdam
        similique ducimus ipsa nostrum neque natus iusto, ea deleniti, dicta
        minima eos mollitia eius omnis totam esse. Perferendis cum impedit
        recusandae.
      </p>
    ),
  },
  {
    id: "usage",
    title: "Starting your journey",
    icon: (
      <Chip capitalize={false} color="danger" component="a" className="py-1">
        <span className="flex gap-2 items-center content-center">
          <span>Waiting for completion of verification</span>
          <ExclamationCircleIcon className="w-4 h-4" />
        </span>
      </Chip>
    ),
    content: (
      <p>
        Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quibusdam
        similique ducimus ipsa nostrum neque natus iusto, ea deleniti, dicta
        minima eos mollitia eius omnis totam esse. Perferendis cum impedit
        recusandae.
      </p>
    ),
  },
];

export const AccordionDemo = ({ code }: { code: string }) => {
  const [selected, setSelected] = useState<null | string>(null);

  return (
    <DemoWrapper className="w-full" code={code}>
      <div className="w-full mx-2 md:mx-[10%] min-h-[60vh] md:min-h-[28rem] flex items-center">
        <Accordion
          selected={selected}
          onSelected={setSelected}
          className="w-full my-2"
        >
          {data.map(({ content, icon, id, title }) => (
            <Accordion.Item id={id} key={id}>
              <Accordion.ItemTitle className="flex flex-col gap-2 md:flex-row justify-between items-center content-center text-left">
                <span>{title}</span>
                <span className="flex gap-2 items-center content-center gow w-full md:w-auto justify-end">
                  {icon}
                  <ChevronDoubleDownIcon
                    data-selected={selected === id}
                    className="w-4 h-4 transition-transform duration-200 data-[selected=true]:rotate-180"
                  />
                </span>
              </Accordion.ItemTitle>
              <Accordion.ItemBody>{content}</Accordion.ItemBody>
            </Accordion.Item>
          ))}
        </Accordion>
      </div>
    </DemoWrapper>
  );
};

Install Instructions

Copy and paste the code into that file

import {
  type ComponentPropsWithoutRef,
  type MouseEventHandler,
  createContext,
  useCallback,
  useContext,
  useEffect,
  useRef,
} from "react";

//----- Third Party Utils -----//
import { twMerge } from "tailwind-merge";

//----- AccordionContext -----//
const AccordionContext = createContext<{
  selected: string | null;
  setSelected: (v: string | null) => void;
}>({
  selected: null,
  setSelected: () => {},
});

//----- Accordion -----//
interface AccordionProps extends ComponentPropsWithoutRef<"div"> {
  selected?: string | null;
  onSelected?: (v: string | null) => void;
  bodyElementBorderWidth?: number;
}

export const Accordion = ({
  children,
  onSelected,
  selected = null,
  className = "",
  bodyElementBorderWidth = 2,
  ...props
}: AccordionProps) => {
  const ref = useRef<HTMLDivElement | null>(null);

  /**
   * Animates the content of selected item
   * @param {string|null} selected
   * @returns {void}
   */
  const onSelectHandler = useCallback(
    (selected: string | null): void => {
      // Verifying the accordion
      const accordion = ref.current;
      if (accordion === null) return;

      accordion.childNodes.forEach((element) => {
        const accordionItem = element as HTMLDivElement;
        const isSelected = accordionItem.id === selected;

        let selectedBodyHeight = 0;

        // Finding the body element
        const accordionItemBody = accordionItem.querySelector(
          "[data-accordion-body]"
        );
        if (accordionItemBody === null) return;

        // Height of `AccordionItemTitle` + the border of `AccordionItem`
        const accordionItemTitle =
          (
            accordionItem.firstChild as HTMLButtonElement
          ).getBoundingClientRect().height +
          // Incase you have changed the border width inside the `AccordionItem` you need to apply it to `bodyElementBorderWidth` prop
          bodyElementBorderWidth;

        // Applying th states

        // Applying the selected attribute to `AccordionItemBody`
        accordionItemBody.setAttribute("data-selected", `${isSelected}`);

        if (isSelected && accordionItemBody) {
          // Incase the `AccordionItem` is selected one, adding the height `AccordionItemBody` of it to it.
          selectedBodyHeight = accordionItemBody.getBoundingClientRect().height;
          accordionItem.style.setProperty(
            "--max-height",
            selectedBodyHeight + accordionItemTitle + "px"
          );
        } else {
          // Incase the it isn't the selected one, applying the height of it to the `AccordionItemTitle`
          accordionItem.style.setProperty(
            "--max-height",
            accordionItemTitle + "px"
          );
        }

        // Applying max unlimited height for animation purposes
        accordionItem.style.setProperty("--height", "9999vh");
      });

      onSelected && onSelected(selected);
    },
    [onSelected, bodyElementBorderWidth]
  );

  useEffect(() => {
    onSelectHandler(selected);
  }, [selected, onSelectHandler]);

  return (
    <AccordionContext.Provider
      value={{ selected, setSelected: onSelectHandler }}
    >
      <div
        {...props}
        className={twMerge(
          "not-prose transition-all duration-200 motion-reduce:duration-0 flex flex-col gap-2",
          className
        )}
        ref={ref}
      >
        {children}
      </div>
    </AccordionContext.Provider>
  );
};

//----- AccordionItem -----//
interface AccordionItemProps
  extends Omit<ComponentPropsWithoutRef<"div">, "id"> {
  id: string;
}

export const AccordionItem = ({
  id,
  children,
  className = "",
  ...props
}: AccordionItemProps) => (
  <div
    {...props}
    id={id}
    className={twMerge(
      [
        "relative",
        "transition-all",
        "duration-200 motion-reduce:duration-0",
        "border-neutral-300",
        "dark:border-neutral-700",
        "bg-neutral-100",
        "dark:bg-neutral-900",
        "border",
        "rounded-3xl",
        "h-[var(--height,_auto)]",
        "max-h-[var(--max-height)]",
        "will-change-[max-height]",
      ].join(" "),
      className
    )}
  >
    {children}
  </div>
);

//----- AccordionItemTitle -----//
export const AccordionItemTitle = ({
  children,
  className = "",
  onClick,
  ...props
}: ComponentPropsWithoutRef<"button">) => {
  const { setSelected, selected } = useContext(AccordionContext);

  const onSelect = useCallback<MouseEventHandler<HTMLButtonElement>>(
    (e) => {
      const id = (e.target as HTMLButtonElement).parentElement?.id;
      if (typeof id === "undefined") return;

      setSelected(selected === id ? null : id);

      onClick && onClick(e);
    },
    [setSelected, onClick, selected]
  );

  return (
    <button
      onClick={onSelect}
      className={twMerge(
        "block w-full border-none text-md font-semibold  py-2 px-6 [&_*]:pointer-events-none",
        className
      )}
      {...props}
    >
      {children}
    </button>
  );
};

//----- AccordionItemBody -----//
export const AccordionItemBody = ({
  children,
  className = "",
  ...props
}: ComponentPropsWithoutRef<"div">) => (
  <div
    {...props}
    data-accordion-body
    className={twMerge(
      [
        "absolute",
        "duration-200 motion-reduce:duration-0",
        "w-full",
        "transition-all",
        "overflow-hidden",
        "py-4",
        "px-6",
        "data-[selected=false]:[clip-path:_inset(0_0_100%_0_round_calc(theme(borderRadius.3xl)_-_theme(space.2)))]",
        "data-[selected=false]:pointer-events-none",
        "data-[selected=true]:[clip-path:_inset(0_0_0_0_round_calc(theme(borderRadius.3xl)_-_theme(space.2)))]",
      ].join(" "),
      className
    )}
    data-selected={false}
  >
    {children}
  </div>
);

//----- Added Exports -----//
Accordion.Item = AccordionItem;
Accordion.ItemTitle = AccordionItemTitle;
Accordion.ItemBody = AccordionItemBody;

Loading...