Skip to content
Plinth

Cards

Accordion

Panels that open by animating a grid row from 0fr to 1fr, so the content measures itself.

#card#accordion#disclosure#grid#keyboard

Install

npx shadcn@latest add https://plinthui.com/r/accordion.json
Open in v0
'use client';

import { useId, useRef, useState } from 'react';

const css = `
.acc {
  width: min(100%, 440px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.03);
  overflow: hidden;
}

.acc__item + .acc__item { border-top: 1px solid rgba(255, 255, 255, 0.08); }

.acc__heading { margin: 0; }

.acc__trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  width: 100%;
  padding: 14px 16px;
  border: 0;
  background: none;
  color: #f3f0e9;
  font: 500 14.5px/1.4 ui-sans-serif, system-ui, sans-serif;
  text-align: left;
  cursor: pointer;
  transition: background-color 180ms ease;
}
.acc__trigger:hover { background: rgba(255, 255, 255, 0.04); }
.acc__trigger:focus-visible { outline: 2px solid #ffca7a; outline-offset: -2px; }

.acc__chevron {
  flex: none;
  width: 8px;
  height: 8px;
  border-right: 2px solid rgba(255, 202, 122, 0.9);
  border-bottom: 2px solid rgba(255, 202, 122, 0.9);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
.acc__item[data-open="1"] .acc__chevron { transform: rotate(-135deg) translate(-2px, -2px); }

/* The whole trick: a grid row animating 0fr -> 1fr. Height cannot be
   transitioned from auto, so the usual workarounds have to measure the content
   with JS first. A fr row lets the content measure itself. */
.acc__panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.acc__item[data-open="1"] .acc__panel { grid-template-rows: 1fr; }

/* min-height: 0 is required — a grid item's default minimum is its content,
   which would stop the row ever collapsing to zero. */
.acc__inner {
  min-height: 0;
  overflow: hidden;
  padding: 0 16px;
  color: rgba(243, 240, 233, 0.6);
  font: 400 13.5px/1.55 ui-sans-serif, system-ui, sans-serif;
}
.acc__item[data-open="1"] .acc__inner { padding-bottom: 15px; }

@media (prefers-reduced-motion: reduce) {
  .acc__panel, .acc__chevron { transition: none; }
}
`;

export interface AccordionItem {
  title: string;
  body: React.ReactNode;
}

const DEFAULT_ITEMS: AccordionItem[] = [
  {
    title: 'What is a plinth?',
    body: 'The block a sculpture stands on. It does not do the work; it makes the work presentable.',
  },
  {
    title: 'Why not just animate height?',
    body: 'Because you would have to know the height first. Animating a grid row from 0fr to 1fr lets the content measure itself.',
  },
  {
    title: 'Does it work without JavaScript?',
    body: 'The open state is an attribute, so server-rendering it open needs no script. Only toggling does.',
  },
];

export function Accordion({
  items = DEFAULT_ITEMS,
  defaultOpen = 0,
}: {
  items?: AccordionItem[];
  defaultOpen?: number | null;
}) {
  const [open, setOpen] = useState<number | null>(defaultOpen);
  const base = useId();
  const triggers = useRef<(HTMLButtonElement | null)[]>([]);

  return (
    <>
      <style>{css}</style>
      <div className="acc">
        {items.map((item, i) => {
          const isOpen = open === i;
          const panelId = `${base}-panel-${i}`;
          const triggerId = `${base}-trigger-${i}`;
          return (
            <div className="acc__item" key={item.title} data-open={isOpen ? '1' : undefined}>
              <h3 className="acc__heading">
                <button
                  ref={el => {
                    triggers.current[i] = el;
                  }}
                  className="acc__trigger"
                  type="button"
                  id={triggerId}
                  // aria-expanded is what a screen reader reads; the data
                  // attribute only drives CSS. Both move together.
                  aria-expanded={isOpen}
                  aria-controls={panelId}
                  onClick={() => setOpen(isOpen ? null : i)}
                  onKeyDown={e => {
                    // Arrows move between headers, Home/End jump to the ends —
                    // what the accordion pattern expects.
                    const next =
                      e.key === 'ArrowDown'
                        ? i + 1
                        : e.key === 'ArrowUp'
                          ? i - 1
                          : e.key === 'Home'
                            ? 0
                            : e.key === 'End'
                              ? items.length - 1
                              : -1;
                    if (next < 0 || next >= items.length) return;
                    e.preventDefault();
                    triggers.current[next]?.focus();
                  }}
                >
                  {item.title}
                  <span className="acc__chevron" aria-hidden="true" />
                </button>
              </h3>
              <div className="acc__panel" id={panelId} role="region" aria-labelledby={triggerId} aria-hidden={!isOpen} inert={!isOpen}>
                <div className="acc__inner">{item.body}</div>
              </div>
            </div>
          );
        })}
      </div>
    </>
  );
}

More in Cards

Similar tags