Product Page Components Intermediate 10 min read

Product Information Tabs and Accordions

Build tabbed and accordion interfaces for product information. Display descriptions, specifications, size guides, and reviews in an organized way.

Product pages often have lots of information: descriptions, specifications, size guides, care instructions, and reviews. Tabs and accordions help organize this content without overwhelming customers.

Theme Integration

This component is part of the product page component hierarchy:

sections/product-main.liquid
└── <div id="product-page-root">
└── ProductPage (React)
└── ProductTabs ← You are here
├── TabList
└── TabPanel

See Product Page Architecture for the complete Liquid section setup, including product description and metafield data serialization.

Data Source

Prop/StateSourceLiquid Field
product.descriptionLiquid JSONproduct.description
product.descriptionHtmlLiquid JSONproduct.description (HTML)
product.metafields.specificationsLiquid JSONproduct.metafields.custom.specifications
product.metafields.sizeGuideLiquid JSONproduct.metafields.custom.size_guide
product.metafields.careInstructionsLiquid JSONproduct.metafields.custom.care_instructions
activeTabLocal state-
openItemsLocal state- (for accordion)

Product Tabs Component

src/components/product/ProductTabs/ProductTabs.tsx
import { useState } from 'react';
import type { Product } from '@/types/product';
import { TabList } from './TabList';
import { TabPanel } from './TabPanel';
import styles from './ProductTabs.module.css';
interface ProductTabsProps {
product: Product; // Product data with description, metafields, etc.
}
// Tab structure for dynamic tab generation.
interface Tab {
id: string; // Unique identifier for the tab.
label: string; // Display text for the tab button.
content: React.ReactNode; // Content to render when tab is active.
}
/**
* ProductTabs renders a tabbed interface for product information.
* Tabs are dynamically built from product data and metafields.
*/
export function ProductTabs({ product }: ProductTabsProps) {
// Build tabs based on available product data.
const tabs = buildTabs(product);
// Track which tab is currently active.
const [activeTab, setActiveTab] = useState(tabs[0]?.id || '');
// Don't render if no tabs available.
if (tabs.length === 0) return null;
return (
<div className={styles.tabs}>
{/* Tab buttons with keyboard navigation */}
<TabList
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Render all panels, showing only the active one */}
{tabs.map((tab) => (
<TabPanel key={tab.id} id={tab.id} isActive={tab.id === activeTab}>
{tab.content}
</TabPanel>
))}
</div>
);
}
/**
* Dynamically build tabs from product data.
* Only creates tabs for content that exists.
*/
function buildTabs(product: Product): Tab[] {
const tabs: Tab[] = [];
// Description tab - always first if available.
if (product.description) {
tabs.push({
id: 'description',
label: 'Description',
content: (
<div
className={styles.content}
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
/>
),
});
}
// Specifications from metafield - renders as a table.
const specs = product.metafields.specifications as Record<string, string> | null;
if (specs && Object.keys(specs).length > 0) {
tabs.push({
id: 'specifications',
label: 'Specifications',
content: <SpecificationsTable specs={specs} />,
});
}
// Size guide from metafield - typically HTML content.
const sizeGuide = product.metafields.sizeGuide as string | null;
if (sizeGuide) {
tabs.push({
id: 'size-guide',
label: 'Size Guide',
content: <div className={styles.content} dangerouslySetInnerHTML={{ __html: sizeGuide }} />,
});
}
// Care instructions - plain text.
const care = product.metafields.careInstructions as string | null;
if (care) {
tabs.push({
id: 'care',
label: 'Care',
content: <div className={styles.content}>{care}</div>,
});
}
return tabs;
}
/**
* Renders product specifications as a two-column table.
*/
function SpecificationsTable({ specs }: { specs: Record<string, string> }) {
return (
<table className={styles.specTable}>
<tbody>
{Object.entries(specs).map(([key, value]) => (
<tr key={key}>
<th>{key}</th>
<td>{value}</td>
</tr>
))}
</tbody>
</table>
);
}

Tab List Component

src/components/product/ProductTabs/TabList.tsx
import styles from './TabList.module.css';
interface Tab {
id: string;
label: string;
}
interface TabListProps {
tabs: Tab[]; // Array of tab definitions.
activeTab: string; // ID of the currently active tab.
onTabChange: (tabId: string) => void; // Callback when tab changes.
}
/**
* TabList renders the row of tab buttons with full keyboard navigation.
* Follows ARIA tabs pattern for accessibility.
*/
export function TabList({ tabs, activeTab, onTabChange }: TabListProps) {
// Handle keyboard navigation between tabs.
const handleKeyDown = (event: React.KeyboardEvent, index: number) => {
let newIndex = index;
switch (event.key) {
case 'ArrowLeft': // Move to previous tab (wraps to end).
newIndex = index > 0 ? index - 1 : tabs.length - 1;
break;
case 'ArrowRight': // Move to next tab (wraps to start).
newIndex = index < tabs.length - 1 ? index + 1 : 0;
break;
case 'Home': // Jump to first tab.
newIndex = 0;
break;
case 'End': // Jump to last tab.
newIndex = tabs.length - 1;
break;
default:
return; // Don't prevent default for other keys.
}
event.preventDefault();
onTabChange(tabs[newIndex].id);
};
return (
<div className={styles.list} role="tablist">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab" // ARIA role for tab buttons.
id={`tab-${tab.id}`} // Links to aria-labelledby on panel.
aria-selected={tab.id === activeTab} // Indicates selection state.
aria-controls={`panel-${tab.id}`} // Links to controlled panel.
tabIndex={tab.id === activeTab ? 0 : -1} // Roving tabindex pattern.
className={`${styles.tab} ${tab.id === activeTab ? styles.active : ''}`}
onClick={() => onTabChange(tab.id)}
onKeyDown={(event) => handleKeyDown(event, index)}
>
{tab.label}
</button>
))}
</div>
);
}
src/components/product/ProductTabs/TabList.module.css
/* Container for tab buttons - horizontal scrollable on narrow screens. */
.list {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
overflow-x: auto; /* Allow horizontal scroll if tabs overflow. */
}
/* Individual tab button styling. */
.tab {
padding: 1rem 1.5rem;
border: none;
border-bottom: 2px solid transparent; /* Underline indicator. */
background: transparent;
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap; /* Prevent text wrapping. */
transition: color 0.15s ease, border-color 0.15s ease;
}
.tab:hover {
color: var(--color-text);
}
/* Active tab styling - underline and full color. */
.tab.active {
color: var(--color-text);
border-bottom-color: var(--color-text);
}

Tab Panel Component

src/components/product/ProductTabs/TabPanel.tsx
import styles from './TabPanel.module.css';
interface TabPanelProps {
id: string; // Panel ID, matches tab ID.
isActive: boolean; // Whether this panel is currently visible.
children: React.ReactNode; // Panel content.
}
/**
* TabPanel renders a single tab's content.
* Uses hidden attribute for inactive panels (accessible and performant).
*/
export function TabPanel({ id, isActive, children }: TabPanelProps) {
return (
<div
role="tabpanel" // ARIA role for tab content.
id={`panel-${id}`} // Links to aria-controls on tab button.
aria-labelledby={`tab-${id}`} // Links to the tab that labels this panel.
hidden={!isActive} // Native hidden attribute for inactive panels.
className={styles.panel}
>
{children}
</div>
);
}
src/components/product/ProductTabs/TabPanel.module.css
.panel {
padding: 1.5rem 0;
}
/* Ensure hidden panels are not displayed. */
.panel[hidden] {
display: none;
}

Accordion Alternative

For mobile or simpler layouts, use accordions:

src/components/product/ProductAccordion/ProductAccordion.tsx
import { useState } from 'react';
import type { Product } from '@/types/product';
import { AccordionItem } from './AccordionItem';
import styles from './ProductAccordion.module.css';
interface ProductAccordionProps {
product: Product; // Product data with description, metafields, etc.
allowMultiple?: boolean; // If true, multiple items can be open at once.
}
/**
* ProductAccordion renders product information as collapsible sections.
* Better for mobile than tabs. Can allow single or multiple open items.
*/
export function ProductAccordion({ product, allowMultiple = false }: ProductAccordionProps) {
// Track which items are currently open. Default: description open.
const [openItems, setOpenItems] = useState<Set<string>>(new Set(['description']));
// Toggle an item's open/closed state.
const toggleItem = (itemId: string) => {
setOpenItems((current) => {
const next = new Set(current);
if (next.has(itemId)) {
// Item is open, close it.
next.delete(itemId);
} else {
// Item is closed, open it.
if (!allowMultiple) {
// Single mode: close all others first.
next.clear();
}
next.add(itemId);
}
return next;
});
};
// Build accordion items from product data.
const items = buildAccordionItems(product);
return (
<div className={styles.accordion}>
{items.map((item) => (
<AccordionItem
key={item.id}
id={item.id}
title={item.title}
isOpen={openItems.has(item.id)}
onToggle={() => toggleItem(item.id)}
>
{item.content}
</AccordionItem>
))}
</div>
);
}
/**
* Build accordion items from product data (similar to buildTabs).
*/
function buildAccordionItems(product: Product) {
const items = [];
// Description section.
if (product.description) {
items.push({
id: 'description',
title: 'Description',
content: <div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />,
});
}
// Specifications from metafield using definition list.
const specs = product.metafields.specifications as Record<string, string> | null;
if (specs) {
items.push({
id: 'specifications',
title: 'Specifications',
content: (
<dl>
{Object.entries(specs).map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
),
});
}
return items;
}

Accordion Item Component

src/components/product/ProductAccordion/AccordionItem.tsx
import { useRef } from 'react';
import styles from './AccordionItem.module.css';
interface AccordionItemProps {
id: string; // Unique identifier for ARIA attributes.
title: string; // Display text for the trigger button.
isOpen: boolean; // Whether the content is expanded.
onToggle: () => void; // Called when trigger is clicked.
children: React.ReactNode; // Content to show when expanded.
}
/**
* AccordionItem renders a collapsible section with smooth animation.
* Uses max-height transition for animated open/close.
*/
export function AccordionItem({
id,
title,
isOpen,
onToggle,
children,
}: AccordionItemProps) {
// Ref to measure content height for animation.
const contentRef = useRef<HTMLDivElement>(null);
return (
<div className={styles.item}>
{/* Trigger button */}
<button
type="button"
className={styles.trigger}
onClick={onToggle}
aria-expanded={isOpen} // ARIA state for screen readers.
aria-controls={`accordion-content-${id}`} // Links to controlled content.
>
<span>{title}</span>
{/* Chevron rotates when open */}
<ChevronIcon className={`${styles.icon} ${isOpen ? styles.open : ''}`} />
</button>
{/* Collapsible content container */}
<div
id={`accordion-content-${id}`}
ref={contentRef}
className={`${styles.content} ${isOpen ? styles.expanded : ''}`}
style={{
// Animate height by setting max-height to actual content height.
maxHeight: isOpen ? contentRef.current?.scrollHeight : 0,
}}
>
<div className={styles.inner}>{children}</div>
</div>
</div>
);
}
/**
* Chevron icon that rotates when accordion is open.
*/
function ChevronIcon({ className }: { className?: string }) {
return (
<svg className={className} width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 8l4 4 4-4" />
</svg>
);
}
src/components/product/ProductAccordion/AccordionItem.module.css
.item {
border-bottom: 1px solid var(--color-border);
}
/* Trigger button - full width with space-between layout. */
.trigger {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 1rem 0;
border: none;
background: transparent;
font-size: 1rem;
font-weight: 500;
text-align: left;
cursor: pointer;
}
/* Chevron icon with rotation animation. */
.icon {
transition: transform 0.2s ease;
}
.icon.open {
transform: rotate(180deg); /* Point up when open. */
}
/* Content container with animated height. */
.content {
overflow: hidden; /* Hide content beyond max-height. */
transition: max-height 0.3s ease; /* Smooth open/close animation. */
}
/* Inner padding for content (separate from animated container). */
.inner {
padding-bottom: 1rem;
}

Responsive: Tabs on Desktop, Accordion on Mobile

src/components/product/ProductInfo/ProductInfo.tsx
import { useMediaQuery } from '@/hooks/useMediaQuery'; // Custom hook for responsive checks.
import type { Product } from '@/types/product';
import { ProductTabs } from '../ProductTabs';
import { ProductAccordion } from '../ProductAccordion';
interface ProductInfoProps {
product: Product;
}
/**
* ProductInfo provides responsive product information display.
* Uses tabs on desktop for horizontal navigation.
* Uses accordion on mobile for better touch interaction.
*/
export function ProductInfo({ product }: ProductInfoProps) {
// Check if viewport is mobile-sized.
const isMobile = useMediaQuery('(max-width: 767px)');
// Mobile: accordion with multiple items allowed open.
if (isMobile) {
return <ProductAccordion product={product} allowMultiple />;
}
// Desktop: horizontal tabs.
return <ProductTabs product={product} />;
}

Key Takeaways

  1. ARIA roles: Use tablist, tab, tabpanel for tabs
  2. Keyboard navigation: Arrow keys for tabs, focus management
  3. Dynamic content: Build tabs from metafields
  4. Responsive pattern: Tabs on desktop, accordion on mobile
  5. Animation: Smooth height transitions for accordions
  6. Multiple open: Option to allow multiple accordion items open

In the next lesson, we’ll build the Recommendations Carousel.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...