INP-optimized, accessible accordion component with SEO-friendly architecture.
- ✅ INP-optimized with
useCallbackWithYield - ✅ SEO-friendly (content always in DOM)
- ✅ CSS Grid animations (200ms, hardware-accelerated)
- ✅ WAI-ARIA compliant
- ✅ Controlled and uncontrolled modes
- ✅ Multiple items can be open simultaneously
- ✅ Keyboard navigation (Tab key)
- ✅ TypeScript first
import {
TpAccordion,
TpAccordionItem,
TpAccordionTrigger,
TpAccordionContent,
} from '@travelopia/react-components'
function FAQ() {
return (
<TpAccordion defaultValue={['item-1']}>
<TpAccordionItem value="item-1">
<TpAccordionTrigger>What is your refund policy?</TpAccordionTrigger>
<TpAccordionContent>
<p>You can request a full refund within 30 days of purchase.</p>
</TpAccordionContent>
</TpAccordionItem>
<TpAccordionItem value="item-2">
<TpAccordionTrigger>How do I contact support?</TpAccordionTrigger>
<TpAccordionContent>
<p>Email us at support@example.com or use our live chat.</p>
</TpAccordionContent>
</TpAccordionItem>
</TpAccordion>
)
}import { useState } from 'react'
function ControlledAccordion() {
const [value, setValue] = useState<string[]>(['item-1'])
return (
<>
<button onClick={() => setValue(['item-1', 'item-2'])}>
Open First Two
</button>
<button onClick={() => setValue([])}>
Close All
</button>
<TpAccordion value={value} onValueChange={setValue}>
<TpAccordionItem value="item-1">
<TpAccordionTrigger>Section 1</TpAccordionTrigger>
<TpAccordionContent>Content 1</TpAccordionContent>
</TpAccordionItem>
<TpAccordionItem value="item-2">
<TpAccordionTrigger>Section 2</TpAccordionTrigger>
<TpAccordionContent>Content 2</TpAccordionContent>
</TpAccordionItem>
</TpAccordion>
</>
)
}import { useTpAccordion } from '@travelopia/react-components'
function HookExample() {
const accordion = useTpAccordion(['item-1'])
return (
<>
<button onClick={() => accordion.toggleItem('item-1')}>
Toggle Item 1
</button>
<button onClick={() => accordion.openAll(['item-1', 'item-2', 'item-3'])}>
Open All
</button>
<button onClick={() => accordion.closeAll()}>
Close All
</button>
<TpAccordion value={accordion.value} onValueChange={(v) => accordion.openAll(v)}>
<TpAccordionItem value="item-1">
<TpAccordionTrigger>Item 1</TpAccordionTrigger>
<TpAccordionContent>Content 1</TpAccordionContent>
</TpAccordionItem>
<TpAccordionItem value="item-2">
<TpAccordionTrigger>Item 2</TpAccordionTrigger>
<TpAccordionContent>Content 2</TpAccordionContent>
</TpAccordionItem>
<TpAccordionItem value="item-3">
<TpAccordionTrigger>Item 3</TpAccordionTrigger>
<TpAccordionContent>Content 3</TpAccordionContent>
</TpAccordionItem>
</TpAccordion>
</>
)
}<TpAccordion>
<TpAccordionItem
value="item-1"
className="border border-gray-200 rounded-lg mb-2 overflow-hidden"
>
<TpAccordionTrigger className="w-full px-4 py-3 text-left font-semibold bg-gray-50 hover:bg-gray-100 aria-expanded:bg-blue-500 aria-expanded:text-white transition-colors">
Question 1
</TpAccordionTrigger>
<TpAccordionContent className="bg-white">
<div className="px-4 py-3 text-gray-700">
Answer 1
</div>
</TpAccordionContent>
</TpAccordionItem>
</TpAccordion>Root component that manages accordion state.
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string[] |
- | Controlled state - array of open item values |
defaultValue |
string[] |
[] |
Uncontrolled default open items |
onValueChange |
(value: string[]) => void |
- | Callback when open state changes |
children |
ReactNode |
- | Accordion items |
Individual accordion item wrapper.
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string |
required | Unique identifier for this item |
className |
string |
- | Custom CSS class |
children |
ReactNode |
- | Trigger and content components |
The clickable button that toggles the accordion item.
| Prop | Type | Default | Description |
|---|---|---|---|
className |
string |
- | Custom CSS class |
children |
ReactNode |
- | Trigger content (usually text) |
ARIA Attributes:
aria-expanded:truewhen open,falsewhen closedaria-controls: Links to content IDtype="button": Proper button semantics
The collapsible content area.
| Prop | Type | Default | Description |
|---|---|---|---|
className |
string |
- | Custom CSS class |
children |
ReactNode |
- | Content to display |
ARIA Attributes:
aria-labelledby: Links to trigger IDdata-state:"open"or"closed"for CSS targeting
Semantic HTML:
- Renders as
<section>element (implicitregionrole)
Custom hook to manage accordion state.
const accordion = useTpAccordion(defaultValue?: string[])Returns:
{
value: string[] // Current open items
openItem: (itemValue: string) => void // Open specific item
closeItem: (itemValue: string) => void // Close specific item
toggleItem: (itemValue: string) => void // Toggle specific item
openAll: (itemValues: string[]) => void // Set open items
closeAll: () => void // Close all items
}Example:
const accordion = useTpAccordion(['item-1'])
<button onClick={() => accordion.openItem('item-2')}>Open Item 2</button>
<button onClick={() => accordion.closeItem('item-1')}>Close Item 1</button>
<button onClick={() => accordion.toggleItem('item-3')}>Toggle Item 3</button>
<button onClick={() => accordion.openAll(['item-1', 'item-2'])}>Open Multiple</button>
<button onClick={() => accordion.closeAll()}>Close All</button>The trigger's click handler uses useCallbackWithYield which automatically yields to the main thread before updating state:
// Internal implementation
const handleClick = useCallbackWithYield(() => {
onItemToggle(value)
}, [onItemToggle, value])This improves INP (Interaction to Next Paint) by allowing the browser to paint visual feedback before executing heavy state updates.
Instead of animating height (which triggers layout recalculation), we use CSS Grid's grid-template-rows:
.content {
display: grid;
grid-template-rows: 0fr; /* Closed */
transition: grid-template-rows 200ms ease;
}
.content[data-state="open"] {
grid-template-rows: 1fr; /* Open */
}This is hardware-accelerated and performs better than traditional height animations.
Content is always in the DOM (not unmounted when closed), making it:
- ✅ Searchable with Ctrl+F
- ✅ Indexable by search engines
- ✅ Accessible to screen readers
- Tab: Move focus between triggers
- Enter/Space: Toggle accordion item (when trigger is focused)
- Triggers announce their expanded/collapsed state
- Content regions are properly labeled
- Semantic HTML (
<button>,<section>) for better compatibility
<button
id="trigger-id"
aria-expanded="true"
aria-controls="content-id"
>
Trigger Text
</button>
<section
id="content-id"
aria-labelledby="trigger-id"
>
Content
</section>Use aria-expanded and data-state for state-based styling:
/* Trigger when open */
.my-trigger[aria-expanded="true"] {
background: blue;
color: white;
}
/* Content when open */
.my-content[data-state="open"] {
/* Custom styles */
}
/* Content when closed */
.my-content[data-state="closed"] {
/* Custom styles */
}