Accordion and Tabs Implementation for Website

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1215
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1043
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    815

Implementing Accordion and Tabs on Website

Accordions and tabs are basic content layout patterns. Making them "just work" is easy. Making them accessible, SEO-friendly, and without flashing on load — requires care.

Accordion: Native HTML

Native <details>/<summary> works without JS, has built-in semantics, and can be animated via CSS:

<div class="accordion" role="list">
  <details class="accordion__item" role="listitem">
    <summary class="accordion__trigger">
      How to place an order?
      <span class="accordion__icon" aria-hidden="true"></span>
    </summary>
    <div class="accordion__content">
      <p>Select products, go to cart and click "Place Order".</p>
    </div>
  </details>

  <details class="accordion__item" role="listitem">
    <summary class="accordion__trigger">
      What payment methods available?
    </summary>
    <div class="accordion__content">
      <p>Card, cash on delivery, wire transfer.</p>
    </div>
  </details>
</div>
.accordion__item {
  border-bottom: 1px solid #e2e8f0;
}

.accordion__trigger {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 0;
  cursor: pointer;
  list-style: none;  /* hide native marker */
  font-weight: 500;
  user-select: none;
}

.accordion__trigger::-webkit-details-marker {
  display: none;  /* Safari */
}

.accordion__icon::before {
  content: '+';
  font-size: 20px;
  transition: transform 0.2s;
}

details[open] .accordion__icon::before {
  content: '−';
}

/* Animation via @starting-style (Chrome 117+, Firefox 129+) */
.accordion__content {
  overflow: hidden;
}

@supports (interpolate-size: allow-keywords) {
  .accordion__content {
    interpolate-size: allow-keywords;
    height: 0;
    transition: height 0.3s ease;
  }

  details[open] .accordion__content {
    height: auto;
  }
}

For browsers without interpolate-size — JavaScript animation:

document.querySelectorAll('details.accordion__item').forEach(details => {
  const content = details.querySelector('.accordion__content') as HTMLElement

  details.addEventListener('toggle', () => {
    if (details.open) {
      const height = content.scrollHeight
      content.style.height = '0'
      requestAnimationFrame(() => {
        content.style.transition = 'height 0.3s ease'
        content.style.height = `${height}px`
        content.addEventListener('transitionend', () => {
          content.style.height = 'auto'
        }, { once: true })
      })
    } else {
      content.style.height = `${content.scrollHeight}px`
      requestAnimationFrame(() => {
        content.style.transition = 'height 0.3s ease'
        content.style.height = '0'
      })
    }
  })
})

Accordion: React Component

import { useState, useRef, useEffect } from 'react'

interface AccordionItem {
  id: string
  question: string
  answer: React.ReactNode
}

function AccordionItem({ item, isOpen, onToggle }: {
  item: AccordionItem
  isOpen: boolean
  onToggle: () => void
}) {
  const contentRef = useRef<HTMLDivElement>(null)
  const [height, setHeight] = useState(0)

  useEffect(() => {
    if (contentRef.current) {
      setHeight(isOpen ? contentRef.current.scrollHeight : 0)
    }
  }, [isOpen])

  return (
    <div className="accordion-item">
      <button
        className="accordion-item__trigger"
        onClick={onToggle}
        aria-expanded={isOpen}
        aria-controls={`accordion-content-${item.id}`}
        id={`accordion-btn-${item.id}`}
      >
        {item.question}
        <svg className={`accordion-item__chevron ${isOpen ? 'rotated' : ''}`} viewBox="0 0 24 24">
          <path d="M6 9l6 6 6-6" stroke="currentColor" fill="none" strokeWidth="2"/>
        </svg>
      </button>
      <div
        id={`accordion-content-${item.id}`}
        role="region"
        aria-labelledby={`accordion-btn-${item.id}`}
        style={{ height, overflow: 'hidden', transition: 'height 0.3s ease' }}
      >
        <div ref={contentRef} className="accordion-item__body">
          {item.answer}
        </div>
      </div>
    </div>
  )
}

export function Accordion({ items, allowMultiple = false }: {
  items: AccordionItem[]
  allowMultiple?: boolean
}) {
  const [openIds, setOpenIds] = useState<Set<string>>(new Set())

  function toggle(id: string) {
    setOpenIds(prev => {
      const next = new Set(prev)
      if (next.has(id)) {
        next.delete(id)
      } else {
        if (!allowMultiple) next.clear()
        next.add(id)
      }
      return next
    })
  }

  return (
    <div className="accordion">
      {items.map(item => (
        <AccordionItem
          key={item.id}
          item={item}
          isOpen={openIds.has(item.id)}
          onToggle={() => toggle(item.id)}
        />
      ))}
    </div>
  )
}

Tabs: ARIA Pattern

interface Tab {
  id: string
  label: string
  content: React.ReactNode
}

export function Tabs({ tabs, defaultTab }: { tabs: Tab[]; defaultTab?: string }) {
  const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id)
  const tablistRef = useRef<HTMLDivElement>(null)

  // Keyboard navigation with arrows
  function handleKeyDown(e: React.KeyboardEvent, currentIndex: number) {
    let newIndex = currentIndex
    if (e.key === 'ArrowRight') newIndex = (currentIndex + 1) % tabs.length
    else if (e.key === 'ArrowLeft') newIndex = (currentIndex - 1 + tabs.length) % tabs.length
    else if (e.key === 'Home') newIndex = 0
    else if (e.key === 'End') newIndex = tabs.length - 1
    else return

    e.preventDefault()
    setActiveId(tabs[newIndex].id)
    // Move focus
    const tabEls = tablistRef.current?.querySelectorAll('[role="tab"]')
    ;(tabEls?.[newIndex] as HTMLElement)?.focus()
  }

  return (
    <div className="tabs">
      <div role="tablist" ref={tablistRef} className="tabs__list">
        {tabs.map((tab, i) => (
          <button
            key={tab.id}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={activeId === tab.id}
            aria-controls={`tabpanel-${tab.id}`}
            tabIndex={activeId === tab.id ? 0 : -1}
            onClick={() => setActiveId(tab.id)}
            onKeyDown={e => handleKeyDown(e, i)}
            className={`tabs__tab ${activeId === tab.id ? 'tabs__tab--active' : ''}`}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map(tab => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`tabpanel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeId !== tab.id}
          tabIndex={0}
          className="tabs__panel"
        >
          {tab.content}
        </div>
      ))}
    </div>
  )
}

URL Synchronization for Tabs

Tabs not reflected in URL break the back button and sharing:

import { useSearchParams } from 'react-router-dom'

function UrlTabs({ tabs }: { tabs: Tab[] }) {
  const [params, setParams] = useSearchParams()
  const activeId = params.get('tab') ?? tabs[0].id

  function setTab(id: string) {
    setParams(prev => { prev.set('tab', id); return prev }, { replace: true })
  }

  return <Tabs tabs={tabs} defaultTab={activeId} onTabChange={setTab} />
}

SEO: Tab Content for Search Engines

Hidden tab content with hidden or display: none is indexed by Google but with less weight. For SEO-important content use visibility: hidden + height: 0 instead of display: none, or server-render all panels:

// SSR: render all panels, hide via CSS
<div
  role="tabpanel"
  hidden={activeId !== tab.id}
  // hidden = display:none in browser, but in SSR HTML shows content
  // Google sees content in HTML
>

Schema.org for FAQ Accordion

function FaqAccordion({ items }: { items: AccordionItem[] }) {
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: items.map(item => ({
      '@type': 'Question',
      name: item.question,
      acceptedAnswer: {
        '@type': 'Answer',
        text: typeof item.answer === 'string' ? item.answer : '',
      },
    })),
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
      />
      <Accordion items={items} />
    </>
  )
}

Timeline

Simple accordion or tabs on native HTML — 2–3 hours. React components with animation, ARIA, keyboard navigation — 1 day. System with URL sync, Schema.org markup, tests — 1.5 days.