FAQ Accordion
The FAQ Accordion component provides a flexible and accessible way to display frequently asked questions. It features multiple layout variants, dark themes, smooth animations, and comprehensive customization options through a compound component pattern.
Preview
Centered Layout
- Preview
- Code
Web3 Africa is a community-driven initiative focused on educating and empowering African developers, entrepreneurs, and creators to build and participate in the decentralized web.
Learn more about Web3 Africaimport React, { useState } from 'react';
import {
Accordion,
AccordionItem,
AccordionSummary,
AccordionTitle,
AccordionDetails,
AccordionLink
} from './faq-section';
import { Typography } from '../../../../components/typography';
import { GlobeIcon, PersonIcon, HandshakeIcon } from '@radix-ui/react-icons';
const AccordionExample = () => {
const [openItems, setOpenItems] = useState<string[]>(["1"]);
return (
<Accordion
value={openItems}
onValueChange={setOpenItems}
variant="default"
animationVariant="slide"
iconStyle="chevron"
theme="dark"
enableSingleOpen={false}
>
<AccordionItem id="1">
<AccordionSummary id="1">
<AccordionTitle icon={<GlobeIcon className="w-5 h-5" />} category="General">
What is Web3 Africa?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="1">
<Typography variant="body-small" className="leading-relaxed">
Web3 Africa is a community-driven initiative focused on educating and empowering
African developers, entrepreneurs, and creators to build and participate in the
decentralized web.
</Typography>
<AccordionLink href="/learn-more">
Learn more about Web3 Africa
</AccordionLink>
</AccordionDetails>
</AccordionItem>
<AccordionItem id="2">
<AccordionSummary id="2">
<AccordionTitle icon={<PersonIcon className="w-5 h-5" />} category="Programs">
Who can join the SkillChain Program?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="2">
<Typography variant="body-small" className="leading-relaxed">
The SkillChain Program is open to anyone with a passion for blockchain technology
and Web3 development, from beginners to experienced developers.
</Typography>
</AccordionDetails>
</AccordionItem>
<AccordionItem id="3">
<AccordionSummary id="3">
<AccordionTitle icon={<HandshakeIcon className="w-5 h-5" />} category="Partnership">
How can I become a partner?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="3">
<Typography variant="body-small" className="leading-relaxed">
We're always open to collaborating with organizations that share our vision.
Reach out to our partnership team to explore opportunities.
</Typography>
<AccordionLink href="mailto:partners@web3africa.com">
Contact partnership team
</AccordionLink>
</AccordionDetails>
</AccordionItem>
</Accordion>
);
};
Split Layout
- Preview
- Code
Frequently Asked Questions
Have questions? We're here to help you understand everything about our platform.
Still have questions?
Can't find what you're looking for? Reach out to our support team.
Getting started is easy! Sign up for a free account, complete your profile, and you'll have access to all our basic features.
import React, { useState } from 'react';
import {
Accordion,
AccordionItem,
AccordionSummary,
AccordionTitle,
AccordionDetails
} from './faq-section';
import { Typography } from '../../../../components/typography';
import { RocketIcon, LockClosedIcon, PersonIcon } from '@radix-ui/react-icons';
const AccordionSplitExample = () => {
const [openItems, setOpenItems] = useState<string[]>(["1"]);
const contentSide = "left";
const accentColor = "primary";
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
{/* Content side */}
<div className={contentSide === 'left' ? 'order-1' : 'order-2'}>
<Typography
variant="h2"
weight="bold"
className="text-4xl mb-4 text-gray-900 dark:text-white"
>
Frequently Asked Questions
</Typography>
<Typography
variant="lead"
className="text-lg mb-6 text-gray-500 dark:text-gray-400"
>
Have questions? We're here to help you understand everything about our platform.
</Typography>
<div className={`p-6 rounded-2xl bg-${accentColor}/5 dark:bg-${accentColor}/10`}>
<Typography
variant="h6"
weight="semibold"
className="mb-2 text-gray-900 dark:text-white"
>
Still have questions?
</Typography>
<Typography
variant="body-small"
className="mb-4 text-gray-500 dark:text-gray-400"
>
Can't find what you're looking for? Reach out to our support team.
</Typography>
<button className={`px-4 py-2 bg-${accentColor} text-white rounded-lg hover:bg-${accentColor}/90 transition-colors`}>
Contact Support
</button>
</div>
</div>
{/* Accordion side */}
<div className={contentSide === 'left' ? 'order-2' : 'order-1'}>
<Accordion
value={openItems}
onValueChange={setOpenItems}
variant="default"
animationVariant="slide"
iconStyle="chevron"
theme="dark"
>
<AccordionItem id="1">
<AccordionSummary id="1">
<AccordionTitle icon={<RocketIcon className="w-5 h-5" />}>
How do I get started?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="1">
<Typography variant="body-small">
Getting started is easy! Sign up for a free account, complete your profile,
and you'll have access to all our basic features.
</Typography>
</AccordionDetails>
</AccordionItem>
<AccordionItem id="2">
<AccordionSummary id="2">
<AccordionTitle icon={<LockClosedIcon className="w-5 h-5" />}>
Is my data secure?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="2">
<Typography variant="body-small">
Yes, we take security seriously. All data is encrypted using AES-256,
we're GDPR compliant, and we perform regular security audits.
</Typography>
</AccordionDetails>
</AccordionItem>
<AccordionItem id="3">
<AccordionSummary id="3">
<AccordionTitle icon={<PersonIcon className="w-5 h-5" />}>
Can I invite team members?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="3">
<Typography variant="body-small">
Absolutely! Our team plans allow you to invite unlimited team members with
role-based access control.
</Typography>
</AccordionDetails>
</AccordionItem>
</Accordion>
</div>
</div>
);
};
Vibrant Layout
- Preview
- Code
Frequently Asked Questions
Everything you need to know about our electric blue theme
We use cutting-edge technologies including React, TypeScript, Node.js, and Web3 libraries. Our stack is carefully chosen for performance, security, and developer experience.
Our platform provides analytics, automation tools, and insights that help you make data-driven decisions. Join thousands of businesses that have grown 3x faster with our solutions.
import React, { useState } from 'react';
import {
Accordion,
AccordionItem,
AccordionSummary,
AccordionTitle,
AccordionDetails,
AccordionLink
} from './faq-section';
import { Typography } from '../../../../components/typography';
import {
StarIcon,
TimerIcon,
RocketIcon,
EnvelopeClosedIcon,
MagicWandIcon,
HeartIcon
} from '@radix-ui/react-icons';
const AccordionVibrantExample = () => {
const [openItems, setOpenItems] = useState<string[]>(["1","2"]);
const showIcons = true;
const showCategories = true;
const glassEffect = true;
const activeCategory = "vibrant";
const currentTheme = {
name: "Electric Blue",
accent: "blue",
gradient: "from-blue-600 via-blue-500 to-cyan-500",
bg: "bg-gradient-to-br from-blue-950 via-blue-900 to-cyan-950"
};
const getBackgroundClass = () => {
if (activeCategory === 'glass') {
return `relative overflow-hidden ${glassEffect ? 'backdrop-blur-xl' : ''}`;
}
if (currentTheme.gradient) {
return `bg-gradient-to-br ${currentTheme.gradient}`;
}
return currentTheme.bg;
};
const getGlassBackground = () => {
if (activeCategory !== 'glass') return '';
const baseBg = glassEffect ? 'bg-black/40' : currentTheme.bg;
return `${baseBg} backdrop-blur-md border border-white/20 shadow-xl`;
};
const getTextColor = () => {
if (activeCategory === 'pastel') return 'text-gray-900';
if (activeCategory === 'glass') return 'text-white';
return 'text-white';
};
const getMutedTextColor = () => {
if (activeCategory === 'pastel') return 'text-gray-600';
if (activeCategory === 'glass') return 'text-white/80';
return 'text-white/80';
};
return (
<div className={`relative overflow-hidden rounded-2xl p-8 md:p-12 transition-all duration-500 ${
activeCategory === 'glass'
? getGlassBackground()
: getBackgroundClass()
}`}>
{/* Decorative elements for glass effect */}
{activeCategory === 'glass' && glassEffect && (
<>
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent"></div>
<div className="absolute top-0 left-0 w-64 h-64 bg-gradient-to-br from-white/10 to-transparent rounded-full blur-3xl"></div>
<div className="absolute bottom-0 right-0 w-96 h-96 bg-gradient-to-tl from-white/5 to-transparent rounded-full blur-3xl"></div>
</>
)}
{/* Content */}
<div className="relative z-10">
{/* Header */}
<div className="text-center mb-8">
<div className={`inline-flex items-center gap-2 px-4 py-2 rounded-full ${
activeCategory === 'pastel'
? `bg-${currentTheme.accent}-100 text-${currentTheme.accent}-800`
: activeCategory === 'glass'
? 'bg-white/20 backdrop-blur-sm text-white border border-white/30'
: 'bg-white/10 backdrop-blur-sm text-white'
} mb-4`}>
<StarIcon className="w-4 h-4" />
<span className="text-sm font-medium uppercase">{currentTheme.name}</span>
</div>
<Typography
variant="h2"
weight="bold"
className={`text-3xl md:text-4xl mb-2 ${getTextColor()}`}
>
Frequently Asked Questions
</Typography>
<Typography
variant="lead"
className={getMutedTextColor()}
>
Everything you need to know about our {currentTheme.name.toLowerCase()} theme
</Typography>
</div>
{/* Accordion */}
<Accordion
value={openItems}
onValueChange={setOpenItems}
variant="glass"
animationVariant="slide"
iconStyle="chevron"
theme="dark"
>
<AccordionItem id="1">
<AccordionSummary id="1">
<AccordionTitle
icon={showIcons ? <TimerIcon className="w-5 h-5" /> : undefined}
category={showCategories ? "Technology" : undefined}
>
What technology stack do you use?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="1">
<Typography variant="body-small" className={getMutedTextColor()}>
We use cutting-edge technologies including React, TypeScript, Node.js, and
Web3 libraries. Our stack is carefully chosen for performance, security,
and developer experience.
</Typography>
</AccordionDetails>
</AccordionItem>
<AccordionItem id="2">
<AccordionSummary id="2">
<AccordionTitle
icon={showIcons ? <RocketIcon className="w-5 h-5" /> : undefined}
category={showCategories ? "Growth" : undefined}
>
How can your platform help my business grow?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="2">
<Typography variant="body-small" className={getMutedTextColor()}>
Our platform provides analytics, automation tools, and insights that help
you make data-driven decisions. Join thousands of businesses that have
grown 3x faster with our solutions.
</Typography>
</AccordionDetails>
</AccordionItem>
<AccordionItem id="3">
<AccordionSummary id="3">
<AccordionTitle
icon={showIcons ? <StarIcon className="w-5 h-5" /> : undefined}
category={showCategories ? "Premium" : undefined}
>
What's included in the premium plan?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="3">
<Typography variant="body-small" className={getMutedTextColor()}>
Premium includes unlimited projects, priority support, advanced analytics,
custom integrations, and dedicated account manager. Perfect for growing
businesses and enterprises.
</Typography>
<AccordionLink
href="/pricing"
className={activeCategory === 'pastel' ? `text-${currentTheme.accent}-600` : 'text-white/90 hover:text-white'}
>
View pricing details
</AccordionLink>
</AccordionDetails>
</AccordionItem>
<AccordionItem id="4">
<AccordionSummary id="4">
<AccordionTitle
icon={showIcons ? <EnvelopeClosedIcon className="w-5 h-5" /> : undefined}
category={showCategories ? "Support" : undefined}
>
How do I contact support?
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id="4">
<Typography variant="body-small" className={getMutedTextColor()}>
Premium users get 24/7 priority support via live chat and phone.
Free users can reach us via email with 24-hour response time.
We're always here to help!
</Typography>
</AccordionDetails>
</AccordionItem>
</Accordion>
{/* Footer */}
<div className="mt-8 text-center">
<button className={`px-6 py-3 rounded-xl transition-all duration-300 ${
activeCategory === 'pastel'
? `bg-${currentTheme.accent}-600 text-white hover:bg-${currentTheme.accent}-700`
: activeCategory === 'glass'
? 'bg-white/10 backdrop-blur-sm text-white hover:bg-white/20 border border-white/20'
: 'bg-white/10 backdrop-blur-sm text-white hover:bg-white/20 border border-white/20'
}`}>
Still have questions? Contact us
</button>
</div>
</div>
</div>
);
};
Installation
- CLI
- MANUAL
ignix add component faqAccordion
import React, { useState, useEffect, createContext, useContext } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { cva } from 'class-variance-authority';
import {
ChevronDownIcon,
ChevronRightIcon,
PlusIcon,
MinusIcon,
MagnifyingGlassIcon,
Cross2Icon,
QuestionMarkCircledIcon,
EnvelopeClosedIcon,
CalendarIcon,
ChatBubbleIcon,
ArrowRightIcon,
CheckIcon,
RocketIcon,
LightningBoltIcon,
PersonIcon,
MobileIcon,
PaperPlaneIcon,
LockClosedIcon
} from '@radix-ui/react-icons';
import { cn } from '../../../utils/cn';
import { Button } from '@ignix-ui/button';
import { Typography } from '@ignix-ui/typography';
/* ============================================
CONTEXT & TYPES
============================================ */
// Add these type definitions
type AccordionVariant = 'default' | 'card' | 'bordered' | 'minimal' | 'glass' | 'gradient';
type ThemeVariant = 'light' | 'dark' | 'midnight' | 'charcoal' | 'ocean' | 'forest' | 'sunset';
type AnimationVariant = 'fade' | 'slide' | 'scale' | 'flip' | 'reveal';
type IconStyle = 'chevron' | 'plus-minus' | 'arrow' | 'checkmark';
type LayoutVariant = 'standard' | 'split-left' | 'split-right' | 'grid' | 'minimal-list' | 'contact-sidebar' | 'category-tabs' | 'featured';
// Define the FAQItem interface if not already defined
interface FAQItem {
id: string;
question: string;
answer: string;
category?: string;
icon?: React.ReactNode;
link?: {
text: string;
url: string;
};
}
// Define the FAQCategory interface if not already defined
interface FAQCategory {
id: string;
name: string;
count?: number;
icon?: React.ReactNode;
description?: string;
}
// Define the stat item type
interface StatItem {
label: string;
value: string;
icon: React.ReactNode;
}
/* ============================================
CONTEXT & TYPES
============================================ */
interface AccordionContextType {
openItems: string[];
toggleItem: (id: string) => void;
variant?: AccordionVariant;
animationVariant?: AnimationVariant;
iconStyle?: IconStyle;
theme?: ThemeVariant;
enableSingleOpen?: boolean;
isDark?: boolean;
}
const AccordionContext = createContext<AccordionContextType | undefined>(undefined);
const useAccordionContext = () => {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion compound components must be used within an Accordion');
}
return context;
};
interface ContactOption {
id: string;
type: 'email' | 'chat' | 'phone' | 'schedule';
label: string;
value: string;
icon: React.ReactNode;
action?: () => void;
}
/* ============================================
VARIANTS
============================================ */
const ThemeVariants = cva("", {
variants: {
theme: {
light: "bg-white text-gray-900",
dark: "bg-gray-950 text-gray-50",
midnight: "bg-[#0B1120] text-gray-100",
charcoal: "bg-[#1A1D24] text-gray-100",
ocean: "bg-gradient-to-br from-[#0A1929] via-[#0A1A2F] to-[#0C1E3A] text-gray-100",
forest: "bg-gradient-to-br from-[#0A1F1A] via-[#0C231E] to-[#0E2822] text-gray-100",
sunset: "bg-gradient-to-br from-[#1A0F1F] via-[#201524] to-[#2A1A28] text-gray-100",
},
},
defaultVariants: {
theme: "light",
},
});
// Theme-aware text color variants
const TextColorVariants = cva("", {
variants: {
theme: {
light: "text-gray-900",
dark: "text-gray-50",
midnight: "text-gray-100",
charcoal: "text-gray-100",
ocean: "text-gray-100",
forest: "text-gray-100",
sunset: "text-gray-100",
},
},
defaultVariants: {
theme: "light",
},
});
const MutedTextColorVariants = cva("", {
variants: {
theme: {
light: "text-gray-500",
dark: "text-gray-400",
midnight: "text-gray-400",
charcoal: "text-gray-400",
ocean: "text-gray-300",
forest: "text-gray-300",
sunset: "text-gray-300",
},
},
defaultVariants: {
theme: "light",
},
});
const AccordionItemVariants = cva(
"overflow-hidden transition-all duration-300",
{
variants: {
variant: {
default: "border-b border-border last:border-0",
card: "bg-background rounded-xl shadow-sm border border-border/50 mb-4 last:mb-0 hover:shadow-md transition-shadow",
bordered: "border border-border rounded-xl mb-3 last:mb-0 hover:border-primary/30 transition-colors",
minimal: "border-b border-border/30 last:border-0",
glass: "bg-background/5 backdrop-blur-sm border border-white/10 rounded-xl mb-3 last:mb-0",
gradient: "bg-gradient-to-r from-primary/5 via-transparent to-transparent border-l-4 border-primary rounded-r-xl mb-3 last:mb-0",
},
},
defaultVariants: {
variant: "default",
},
}
);
const QuestionVariants = cva(
"flex items-center justify-between w-full text-left transition-all duration-300 group",
{
variants: {
variant: {
default: "py-4 px-0 hover:bg-accent/5",
card: "py-5 px-6 hover:bg-accent/10",
bordered: "py-4 px-6 hover:bg-accent/5",
minimal: "py-3 px-0 hover:text-primary",
glass: "py-5 px-6 hover:bg-white/5",
gradient: "py-4 px-6 hover:bg-primary/5",
},
},
defaultVariants: {
variant: "default",
},
}
);
const AnswerVariants = cva(
"overflow-hidden",
{
variants: {
variant: {
default: "px-0 pb-4",
card: "px-6 pb-6",
bordered: "px-6 pb-6",
minimal: "px-0 pb-3",
glass: "px-6 pb-6",
gradient: "px-6 pb-5",
},
},
defaultVariants: {
variant: "default",
},
}
);
// Theme-aware category badge variants
const CategoryBadgeVariants = cva(
"text-xs px-2.5 py-1 rounded-full",
{
variants: {
theme: {
light: "bg-primary/10 text-primary",
dark: "bg-primary/20 text-primary-300",
midnight: "bg-primary/20 text-primary-300",
charcoal: "bg-primary/20 text-primary-300",
ocean: "bg-primary/30 text-primary-200",
forest: "bg-primary/30 text-primary-200",
sunset: "bg-primary/30 text-primary-200",
},
},
defaultVariants: {
theme: "light",
},
}
);
// Theme-aware icon container variants
const IconContainerVariants = cva(
"flex-shrink-0",
{
variants: {
theme: {
light: "text-primary",
dark: "text-primary-400",
midnight: "text-primary-400",
charcoal: "text-primary-400",
ocean: "text-primary-300",
forest: "text-primary-300",
sunset: "text-primary-300",
},
},
defaultVariants: {
theme: "light",
},
}
);
/* ============================================
ANIMATION VARIANTS
============================================ */
const answerAnimationVariants = {
fade: {
initial: { opacity: 0, height: 0 },
animate: { opacity: 1, height: "auto" },
exit: { opacity: 0, height: 0 },
},
slide: {
initial: { opacity: 0, y: -20, height: 0 },
animate: { opacity: 1, y: 0, height: "auto" },
exit: { opacity: 0, y: -20, height: 0 },
},
scale: {
initial: { opacity: 0, scaleY: 0, height: 0 },
animate: { opacity: 1, scaleY: 1, height: "auto" },
exit: { opacity: 0, scaleY: 0, height: 0 },
},
flip: {
initial: { opacity: 0, rotateX: -90, height: 0 },
animate: { opacity: 1, rotateX: 0, height: "auto" },
exit: { opacity: 0, rotateX: -90, height: 0 },
},
reveal: {
initial: { opacity: 0, clipPath: "inset(0 0 100% 0)", height: 0 },
animate: { opacity: 1, clipPath: "inset(0 0 0 0)", height: "auto" },
exit: { opacity: 0, clipPath: "inset(0 0 100% 0)", height: 0 },
},
};
/* ============================================
COMPOUND COMPONENTS
============================================ */
// Root Accordion Component
interface AccordionProps {
children: React.ReactNode;
defaultValue?: string[];
value?: string[];
onValueChange?: (value: string[]) => void;
enableSingleOpen?: boolean;
variant?: AccordionVariant;
animationVariant?: AnimationVariant;
iconStyle?: IconStyle;
theme?: ThemeVariant;
className?: string;
}
/**
* Root Accordion component that provides context and state management for all child accordion components.
*
* @component
* @example
* ```tsx
* <Accordion defaultValue={["item1"]} enableSingleOpen variant="bordered">
* <AccordionItem id="item1">
* <AccordionSummary id="item1">Question 1</AccordionSummary>
* <AccordionDetails id="item1">Answer 1</AccordionDetails>
* </AccordionItem>
* </Accordion>
* ```
*/
const Accordion = ({
children,
defaultValue = [],
value,
onValueChange,
enableSingleOpen = false,
variant = 'default',
animationVariant = 'slide',
iconStyle = 'chevron',
theme = 'light',
className,
}: AccordionProps) => {
const [internalOpenItems, setInternalOpenItems] = useState<string[]>(defaultValue);
const openItems = value ?? internalOpenItems;
const isDark = ['dark', 'midnight', 'charcoal', 'ocean', 'forest', 'sunset'].includes(theme);
const toggleItem = (id: string) => {
let newOpenItems: string[];
if (enableSingleOpen) {
newOpenItems = openItems.includes(id) ? [] : [id];
} else {
newOpenItems = openItems.includes(id)
? openItems.filter((itemId) => itemId !== id)
: [...openItems, id];
}
if (onValueChange) {
onValueChange(newOpenItems);
} else {
setInternalOpenItems(newOpenItems);
}
};
return (
<AccordionContext.Provider value={{
openItems,
toggleItem,
variant,
animationVariant,
iconStyle,
theme,
enableSingleOpen,
isDark,
}}>
<div className={cn("w-full", className)}>
{children}
</div>
</AccordionContext.Provider>
);
};
interface AccordionItemProps {
id: string;
children: React.ReactNode;
className?: string;
disabled?: boolean;
}
/**
* Individual accordion item container that wraps a question-answer pair.
*
* @component
* @example
* ```tsx
* <AccordionItem id="faq-1" disabled={false}>
* <AccordionSummary id="faq-1">What is your return policy?</AccordionSummary>
* <AccordionDetails id="faq-1">We offer 30-day returns...</AccordionDetails>
* </AccordionItem>
* ```
*/
const AccordionItem = ({ _id, children, className, disabled = false }: AccordionItemProps) => {
const context = useAccordionContext();
// const isOpen = context.openItems.includes(id);
return (
<div
className={cn(
AccordionItemVariants({ variant: context.variant as AccordionVariant }),
// Default variant - theme aware borders
(context.theme === 'light' || context.isDark) && 'border-gray-200 dark:border-gray-800',
context.theme === 'midnight' && 'border-[#1F2A3F]',
context.theme === 'charcoal' && 'border-[#2D353F]',
context.theme === 'ocean' && 'border-[#1C314F]',
context.theme === 'forest' && 'border-[#1E3A2A]',
context.theme === 'sunset' && 'border-[#3A2A3A]',
// Card variant - theme aware backgrounds
context.theme === 'light' && context.variant === 'card' && 'bg-white',
context.theme === 'dark' && context.variant === 'card' && 'bg-gray-900',
context.theme === 'midnight' && context.variant === 'card' && 'bg-[#0F1524]',
context.theme === 'charcoal' && context.variant === 'card' && 'bg-[#1E242B]',
context.theme === 'ocean' && context.variant === 'card' && 'bg-[#0A1A2F]',
context.theme === 'forest' && context.variant === 'card' && 'bg-[#0C231E]',
context.theme === 'sunset' && context.variant === 'card' && 'bg-[#201524]',
// Bordered variant - theme aware borders
context.theme === 'light' && context.variant === 'bordered' && 'border-gray-200',
context.theme === 'dark' && context.variant === 'bordered' && 'border-gray-800',
context.theme === 'midnight' && context.variant === 'bordered' && 'border-[#1F2A3F]',
context.theme === 'charcoal' && context.variant === 'bordered' && 'border-[#2D353F]',
context.theme === 'ocean' && context.variant === 'bordered' && 'border-[#1C314F]',
context.theme === 'forest' && context.variant === 'bordered' && 'border-[#1E3A2A]',
context.theme === 'sunset' && context.variant === 'bordered' && 'border-[#3A2A3A]',
disabled && "opacity-50 pointer-events-none",
className
)}
>
{children}
</div>
);
};
interface AccordionTitleProps {
children: React.ReactNode;
className?: string;
icon?: React.ReactNode;
category?: string;
}
/**
* Title component for accordion items that displays the question with optional icon and category.
*
* @component
* @example
* ```tsx
* <AccordionTitle
* icon={<QuestionMarkCircledIcon />}
* category="Shipping"
* >
* How long does shipping take?
* </AccordionTitle>
* ```
*/
const AccordionTitle = ({ children, className, icon, category }: AccordionTitleProps) => {
const context = useAccordionContext();
return (
<div className="flex items-start gap-3 flex-1 flex-wrap">
{icon && (
<span className={cn(
IconContainerVariants({ theme: context.theme })
)}>
{icon}
</span>
)}
{category && (
<span className={cn(
CategoryBadgeVariants({ theme: context.theme })
)}>
{category}
</span>
)}
<Typography
variant="body"
weight="medium"
className={cn(
"text-left transition-colors flex-1",
TextColorVariants({ theme: context.theme }),
className
)}
>
{children}
</Typography>
</div>
);
};
// Accordion Summary Component
interface AccordionSummaryProps {
children: React.ReactNode;
className?: string;
id: string;
}
/**
* Interactive button component that toggles the accordion item's open/closed state.
* Displays different icons based on the configured iconStyle prop.
*
* @component
* @example
* ```tsx
* <AccordionSummary id="faq-1">
* <AccordionTitle>What is your return policy?</AccordionTitle>
* </AccordionSummary>
* ```
*/
const AccordionSummary = ({ children, className, id }: AccordionSummaryProps) => {
const context = useAccordionContext();
const isOpen = context.openItems.includes(id);
const getIcon = () => {
const iconClasses = cn(
"w-5 h-5 transition-all duration-300",
context.isDark ? "text-gray-400" : "text-gray-500",
isOpen && (context.isDark ? "text-primary-400" : "text-primary")
);
switch (context.iconStyle) {
case 'chevron':
return (
<ChevronDownIcon
className={cn(
iconClasses,
isOpen ? "rotate-180" : "rotate-0"
)}
/>
);
case 'plus-minus':
return isOpen ? (
<MinusIcon className={iconClasses} />
) : (
<PlusIcon className={iconClasses} />
);
case 'arrow':
return (
<ChevronRightIcon
className={cn(
iconClasses,
isOpen ? "rotate-90" : "rotate-0"
)}
/>
);
case 'checkmark':
return (
<div className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all duration-300",
isOpen
? "bg-primary text-white"
: cn(
"border-2",
context.isDark ? "border-gray-600" : "border-gray-300"
)
)}>
{isOpen && <CheckIcon className="w-3 h-3" />}
</div>
);
default:
return (
<ChevronDownIcon
className={cn(
iconClasses,
isOpen ? "rotate-180" : "rotate-0"
)}
/>
);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
context.toggleItem(id);
}
};
// Theme-aware hover states for summary
const summaryThemeClasses = cn({
'hover:bg-gray-50': context.theme === 'light',
'hover:bg-gray-800': context.theme === 'dark',
'hover:bg-[#1A2332]': context.theme === 'midnight',
'hover:bg-[#2A2F38]': context.theme === 'charcoal',
'hover:bg-[#1A2A3F]': context.theme === 'ocean',
'hover:bg-[#1A332A]': context.theme === 'forest',
'hover:bg-[#2A1A2A]': context.theme === 'sunset',
});
return (
<button
onClick={() => context.toggleItem(id)}
onKeyDown={handleKeyDown}
className={cn(
QuestionVariants({ variant: context.variant }),
summaryThemeClasses,
"cursor-pointer focus:outline-none focus:ring-primary/50 focus:ring-offset-2",
context.theme === 'light' && "focus:ring-offset-white",
context.isDark && "focus:ring-offset-gray-900",
context.theme === 'midnight' && "focus:ring-offset-[#0B1120]",
context.theme === 'charcoal' && "focus:ring-offset-[#1A1D24]",
className
)}
aria-expanded={isOpen}
aria-controls={`faq-answer-${id}`}
id={`faq-question-${id}`}
>
<div className={cn(
"flex-1",
TextColorVariants({ theme: context.theme })
)}>
{children}
</div>
<div className="flex-shrink-0 ml-4">
{getIcon()}
</div>
</button>
);
};
// Accordion Details Component
interface AccordionDetailsProps {
children: React.ReactNode;
className?: string;
id: string;
}
/**
* Content panel that displays the answer when the accordion item is open.
* Features animated enter/exit transitions.
*
* @component
* @example
* ```tsx
* <AccordionDetails id="faq-1">
* <Typography>You can return items within 30 days...</Typography>
* <AccordionLink href="/returns">Learn more about returns</AccordionLink>
* </AccordionDetails>
* ```
*/
const AccordionDetails = ({ children, className, id }: AccordionDetailsProps) => {
const context = useAccordionContext();
const isOpen = context.openItems.includes(id);
return (
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key={`answer-${id}`}
variants={answerAnimationVariants[context.animationVariant || 'slide']}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3, ease: "easeInOut" }}
id={`faq-answer-${id}`}
role="region"
aria-labelledby={`faq-question-${id}`}
>
<div className={cn(
AnswerVariants({ variant: context.variant }),
"prose prose-sm max-w-none",
MutedTextColorVariants({ theme: context.theme }),
context.isDark && "prose-invert",
className
)}>
{children}
</div>
</motion.div>
)}
</AnimatePresence>
);
};
// Accordion Link Component
interface AccordionLinkProps {
href: string;
children: React.ReactNode;
className?: string;
}
/**
* Link component for displaying additional resources or related content within accordion answers.
*
* @component
* @example
* ```tsx
* <AccordionLink href="/shipping-policy">
* Read our full shipping policy
* </AccordionLink>
* ```
*/
const AccordionLink = ({ href, children, className }: AccordionLinkProps) => {
const context = useAccordionContext();
return (
<a
href={href}
className={cn(
"inline-flex items-center gap-2 mt-3",
"text-primary hover:underline transition-colors",
context.isDark && "text-primary-400 hover:text-primary-300",
className
)}
>
{children}
<ArrowRightIcon className="w-3 h-3" />
</a>
);
};
/* ============================================
SUPPORT COMPONENTS
============================================ */
/**
* Search input component for filtering FAQ items with real-time search functionality.
* Includes clear button and loading states.
*
* @component
* @example
* ```tsx
* <FAQSearch
* value={searchQuery}
* onChange={setSearchQuery}
* onClear={handleClearSearch}
* placeholder="Search FAQs..."
* disabled={isSearching}
* theme="light"
* />
* ```
*/
const FAQSearch = ({
value,
onChange,
onClear,
placeholder = "Search frequently asked questions...",
className,
disabled = false,
theme = 'light',
}: {
value: string;
onChange: (value: string) => void;
onClear: () => void;
placeholder?: string;
className?: string;
disabled?: boolean;
theme?: ThemeVariant;
}) => {
const isDark = ['dark', 'midnight', 'charcoal', 'ocean', 'forest', 'sunset'].includes(theme);
const searchThemeClasses = cn({
'bg-white border-gray-200 text-gray-900 placeholder:text-gray-400': theme === 'light',
'bg-gray-900 border-gray-800 text-white placeholder:text-gray-500': theme === 'dark',
'bg-[#0F1524] border-[#1F2A3F] text-white placeholder:text-gray-500': theme === 'midnight',
'bg-[#1E242B] border-[#2D353F] text-white placeholder:text-gray-500': theme === 'charcoal',
'bg-[#0A1A2F] border-[#1C314F] text-white placeholder:text-gray-400': theme === 'ocean',
'bg-[#0C231E] border-[#1E3A2A] text-white placeholder:text-gray-400': theme === 'forest',
'bg-[#201524] border-[#3A2A3A] text-white placeholder:text-gray-400': theme === 'sunset',
});
return (
<div className={cn("relative w-full", className)}>
<div className={cn(
"absolute left-3 top-1/2 -translate-y-1/2",
isDark ? "text-gray-500" : "text-gray-400"
)}>
<MagnifyingGlassIcon className="w-4 h-4" />
</div>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
className={cn(
"w-full h-12 pl-10 pr-10",
"border rounded-xl",
"focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary",
"transition-all duration-300",
searchThemeClasses,
disabled && "opacity-50 cursor-not-allowed",
)}
aria-label="Search FAQs"
/>
{value && (
<button
onClick={onClear}
className={cn(
"absolute right-3 top-1/2 -translate-y-1/2",
"transition-colors duration-200",
"p-1 rounded-full",
isDark
? "text-gray-500 hover:text-gray-300 hover:bg-gray-800"
: "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
)}
aria-label="Clear search"
>
<Cross2Icon className="w-3 h-3" />
</button>
)}
</div>
);
};
/**
* Contact section component displaying multiple ways to get support (email, phone, chat, schedule).
*
* @component
* @example
* ```tsx
* <FAQContactSection
* email="support@example.com"
* phone="+1 (555) 123-4567"
* hasChat={true}
* onContact={(type) => console.log(`Contact via ${type}`)}
* theme="dark"
* />
* ```
*/
const FAQContactSection = ({
email = "support@example.com",
phone = "+1 (555) 123-4567",
hasChat = true,
// scheduleUrl = "/schedule",
onContact,
theme = 'light',
}: {
email?: string;
phone?: string;
hasChat?: boolean;
scheduleUrl?: string;
onContact?: (type: string) => void;
theme?: ThemeVariant;
}) => {
const contactThemeClasses = cn({
'bg-white border border-gray-200': theme === 'light',
'bg-gray-900 border border-gray-800': theme === 'dark',
'bg-[#0F1524] border border-[#1F2A3F]': theme === 'midnight',
'bg-[#1E242B] border border-[#2D353F]': theme === 'charcoal',
'bg-[#0A1A2F] border border-[#1C314F]': theme === 'ocean',
'bg-[#0C231E] border border-[#1E3A2A]': theme === 'forest',
'bg-[#201524] border border-[#3A2A3A]': theme === 'sunset',
});
const buttonThemeClasses = cn({
'bg-gray-50 hover:bg-gray-100': theme === 'light',
'bg-gray-800/50 hover:bg-gray-800': theme === 'dark',
'bg-[#1A2332] hover:bg-[#232E42]': theme === 'midnight',
'bg-[#2A2F38] hover:bg-[#323841]': theme === 'charcoal',
'bg-[#1A2A3F] hover:bg-[#1F314A]': theme === 'ocean',
'bg-[#1A332A] hover:bg-[#1F3F33]': theme === 'forest',
'bg-[#2A1A2A] hover:bg-[#352235]': theme === 'sunset',
});
const isDark = ['dark', 'midnight', 'charcoal', 'ocean', 'forest', 'sunset'].includes(theme);
const contactOptions: ContactOption[] = [
{
id: 'email',
type: 'email' as const,
label: 'Email us',
value: email,
icon: <EnvelopeClosedIcon className="w-5 h-5" />,
},
{
id: 'phone',
type: 'phone' as const,
label: 'Call us',
value: phone,
icon: <MobileIcon className="w-5 h-5" />,
},
...(hasChat ? [{
id: 'chat',
type: 'chat' as const,
label: 'Live chat',
value: 'Available 24/7',
icon: <ChatBubbleIcon className="w-5 h-5" />,
}] : []),
{
id: 'schedule',
type: 'schedule' as const,
label: 'Schedule a call',
value: 'Book a time',
icon: <CalendarIcon className="w-5 h-5" />,
},
];
return (
<div className={cn(
"rounded-2xl p-8",
contactThemeClasses
)}>
<Typography
variant="h5"
weight="semibold"
className={cn(
"mb-2",
isDark ? "text-white" : "text-gray-900"
)}
>
Still have questions?
</Typography>
<Typography
variant="body"
className={cn(
"mb-6",
isDark ? "text-gray-400" : "text-gray-500"
)}
>
Can't find the answer you're looking for? Please reach out to our friendly team.
</Typography>
<div className="space-y-4">
{contactOptions.map((option) => (
<button
key={option.id}
onClick={() => onContact?.(option.type)}
className={cn(
"w-full flex items-center justify-between p-4 rounded-xl",
"transition-all duration-300 group",
buttonThemeClasses,
"hover:scale-[1.02] cursor-pointer"
)}
>
<div className="flex items-center gap-3">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center",
isDark ? "bg-primary/20 text-primary-400" : "bg-primary/10 text-primary"
)}>
{option.icon}
</div>
<div className="text-left">
<Typography
variant="body-small"
weight="medium"
className={isDark ? "text-white" : "text-gray-900"}
>
{option.label}
</Typography>
<Typography
variant="caption"
className={isDark ? "text-gray-400" : "text-gray-500"}
>
{option.value}
</Typography>
</div>
</div>
<ArrowRightIcon className={cn(
"w-4 h-4 transition-all",
isDark ? "text-gray-500 group-hover:text-primary-400" : "text-gray-400 group-hover:text-primary",
"group-hover:translate-x-1"
)} />
</button>
))}
</div>
</div>
);
};
/**
* Tab navigation component for filtering FAQ items by category.
*
* @component
* @example
* ```tsx
* <FAQCategoryTabs
* categories={categories}
* activeCategory={activeCategory}
* onCategoryChange={setActiveCategory}
* theme="light"
* />
* ```
*/
const FAQCategoryTabs = ({
categories,
activeCategory,
onCategoryChange,
theme = 'light',
}: {
categories: FAQCategory[];
activeCategory: string | null;
onCategoryChange: (categoryId: string | null) => void;
theme?: ThemeVariant;
}) => {
const isDark = ['dark', 'midnight', 'charcoal', 'ocean', 'forest', 'sunset'].includes(theme);
const inactiveTabClasses = cn({
'hover:bg-gray-100 text-gray-700': theme === 'light',
'hover:bg-gray-800 text-gray-300': theme === 'dark',
'hover:bg-[#1F2A3F] text-gray-300': theme === 'midnight',
'hover:bg-[#2D353F] text-gray-300': theme === 'charcoal',
'hover:bg-[#1C314F] text-gray-200': theme === 'ocean',
'hover:bg-[#1E3A2A] text-gray-200': theme === 'forest',
'hover:bg-[#3A2A3A] text-gray-200': theme === 'sunset',
});
return (
<div className="flex flex-wrap gap-2 mb-8">
<button
onClick={() => onCategoryChange(null)}
className={cn(
"px-5 py-2.5 rounded-full text-sm font-medium",
"transition-all duration-300",
activeCategory === null
? "bg-primary text-white shadow-lg shadow-primary/25"
: inactiveTabClasses
)}
>
All
<span className="ml-2 text-xs opacity-80">
{categories.reduce((acc, cat) => acc + (cat.count || 0), 0)}
</span>
</button>
{categories.map((category) => (
<button
key={category.id}
onClick={() => onCategoryChange(category.id)}
className={cn(
"px-5 py-2.5 rounded-full text-sm font-medium",
"transition-all duration-300 flex items-center gap-2",
activeCategory === category.id
? "bg-primary text-white shadow-lg shadow-primary/25"
: inactiveTabClasses
)}
>
<span className={isDark ? "text-gray-300" : "text-gray-600"}>
{category.icon}
</span>
{category.name}
{category.count !== undefined && (
<span className="ml-1 text-xs opacity-80">
{category.count}
</span>
)}
</button>
))}
</div>
);
};
/**
* Statistics display component showing key metrics and achievements.
*
* @component
* @example
* ```tsx
* <FAQStats
* stats={[
* { label: "Happy Customers", value: "10K+", icon: <PersonIcon /> },
* { label: "Response Time", value: "< 2h", icon: <LightningBoltIcon /> }
* ]}
* theme="dark"
* />
* ```
*/
const FAQStats = ({
stats,
theme = 'light',
}: {
stats: StatItem[];
theme?: ThemeVariant;
}) => {
const isDark = ['dark', 'midnight', 'charcoal', 'ocean', 'forest', 'sunset'].includes(theme);
const statCardThemeClasses = cn({
'bg-white border border-gray-200': theme === 'light',
'bg-gray-900 border border-gray-800': theme === 'dark',
'bg-[#0F1524] border border-[#1F2A3F]': theme === 'midnight',
'bg-[#1E242B] border border-[#2D353F]': theme === 'charcoal',
'bg-[#0A1A2F] border border-[#1C314F]': theme === 'ocean',
'bg-[#0C231E] border border-[#1E3A2A]': theme === 'forest',
'bg-[#201524] border border-[#3A2A3A]': theme === 'sunset',
});
const getStatIcon = (icon: React.ReactNode) => {
// Since we're not using Lucide React at all, we can simplify this
// The stats prop will now be passed with Radix icons directly
return icon;
};
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{stats.map((stat, index) => (
<div
key={index}
className={cn(
"p-6 rounded-xl text-center",
statCardThemeClasses
)}
>
<div className={cn(
"w-12 h-12 mx-auto mb-3 rounded-full flex items-center justify-center",
isDark ? "bg-primary/20 text-primary-400" : "bg-primary/10 text-primary"
)}>
{getStatIcon(stat.icon)}
</div>
<Typography
variant="h4"
weight="bold"
className={isDark ? "text-white" : "text-gray-900"}
>
{stat.value}
</Typography>
<Typography
variant="caption"
className={isDark ? "text-gray-400" : "text-gray-500"}
>
{stat.label}
</Typography>
</div>
))}
</div>
);
};
/* ============================================
MAIN FAQ SECTION COMPONENT
============================================ */
interface FAQSectionProps {
items: FAQItem[];
categories?: FAQCategory[];
title?: string;
description?: string;
icon?: React.ReactNode;
layoutVariant?: LayoutVariant;
enableSearch?: boolean;
enableCategories?: boolean;
enableSingleOpen?: boolean;
enableDeepLinking?: boolean;
enableContactSection?: boolean;
enableStats?: boolean;
variant?: AccordionVariant;
themeVariant?: ThemeVariant;
animationVariant?: AnimationVariant;
iconStyle?: IconStyle;
inputVariant?: string;
buttonVariant?: string;
customHeader?: React.ReactNode;
customEmptyState?: React.ReactNode;
customSearch?: React.ReactNode;
customCategoryFilter?: React.ReactNode;
customContactSection?: React.ReactNode;
customFooter?: React.ReactNode;
onItemToggle?: (item: FAQItem, isOpen: boolean) => void;
onSearch?: (query: string) => void;
onContact?: (type: string) => void;
isLoading?: boolean;
isSearching?: boolean;
contactEmail?: string;
contactPhone?: string;
contactChat?: boolean;
scheduleUrl?: string;
stats?: StatItem[];
darkMode?: boolean;
className?: string;
}
/**
* Main FAQ section component that combines all sub-components into a complete FAQ solution.
* Supports multiple layout variants, theming, search, categories, and contact integration.
*
* @component
* @example
* ```tsx
* <FAQSection
* title="Frequently Asked Questions"
* description="Find answers to common questions"
* items={faqItems}
* categories={faqCategories}
* layoutVariant="split-left"
* enableSearch={true}
* enableCategories={true}
* themeVariant="ocean"
* onContact={(type) => handleContact(type)}
* />
* ```
*
* @example
* ```tsx
* // Featured layout with stats
* <FAQSection
* items={faqItems}
* layoutVariant="featured"
* enableStats={true}
* enableContactSection={true}
* themeVariant="forest"
* stats={supportStats}
* />
* ```
*/
const FAQSection = ({
items,
categories = [],
title = "Frequently asked questions",
description = "We're happy to answer your questions",
// icon = <QuestionMarkCircledIcon className="w-5 h-5" />,
layoutVariant = "standard",
enableSearch = false,
enableCategories = false,
enableSingleOpen = false,
// enableDeepLinking = false,
enableContactSection = false,
enableStats = false,
variant = "default",
themeVariant = "light",
animationVariant = "slide",
iconStyle = "chevron",
// inputVariant = "clean",
// buttonVariant = "default",
customHeader,
customEmptyState,
customSearch,
customCategoryFilter,
customContactSection,
customFooter,
// onItemToggle,
onSearch,
onContact,
isLoading = false,
isSearching = false,
contactEmail = "support@example.com",
contactPhone = "+1 (555) 123-4567",
contactChat = true,
scheduleUrl = "/schedule",
stats = [
{ label: "Happy Customers", value: "10K+", icon: <PersonIcon className="w-5 h-5" /> },
{ label: "Questions Answered", value: "50K+", icon: <ChatBubbleIcon className="w-5 h-5" /> },
{ label: "Response Time", value: "< 2h", icon: <LightningBoltIcon className="w-5 h-5" /> },
{ label: "Satisfaction", value: "98%", icon: <LockClosedIcon className="w-5 h-5" /> },
],
darkMode,
className,
}: FAQSectionProps) => {
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [filteredItems, setFilteredItems] = useState<FAQItem[]>(items);
const [openItems, setOpenItems] = useState<string[]>([]);
const effectiveTheme = darkMode ? 'dark' : themeVariant;
const isDark = darkMode || ['dark', 'midnight', 'charcoal', 'ocean', 'forest', 'sunset'].includes(themeVariant);
const themeClass = ThemeVariants({ theme: effectiveTheme });
useEffect(() => {
let result = [...items];
if (enableCategories && activeCategory) {
result = result.filter(item => item.category === activeCategory);
}
if (enableSearch && searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(item =>
item.question.toLowerCase().includes(query) ||
item.answer.toLowerCase().includes(query)
);
}
setFilteredItems(result);
onSearch?.(searchQuery);
}, [items, searchQuery, activeCategory, enableSearch, enableCategories, onSearch]);
const handleClearSearch = () => {
setSearchQuery('');
};
const handleCategoryChange = (categoryId: string | null) => {
setActiveCategory(categoryId);
};
const categoriesWithCounts = categories.map(cat => ({
...cat,
count: items.filter(item => item.category === cat.id).length
}));
const renderFAQContent = () => {
const faqList = (
<Accordion
value={openItems}
onValueChange={setOpenItems}
enableSingleOpen={enableSingleOpen}
variant={variant}
animationVariant={animationVariant}
iconStyle={iconStyle}
theme={effectiveTheme}
>
{filteredItems.length > 0 ? (
filteredItems.map((item) => (
<AccordionItem key={item.id} id={item.id}>
<AccordionSummary id={item.id}>
<AccordionTitle icon={item.icon} category={item.category}>
{item.question}
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id={item.id}>
<Typography
variant="body-small"
className={cn(
"leading-relaxed",
isDark ? "text-gray-400" : "text-gray-600"
)}
>
{item.answer}
</Typography>
{item.link && (
<AccordionLink href={item.link.url}>
{item.link.text}
</AccordionLink>
)}
</AccordionDetails>
</AccordionItem>
))
) : (
customEmptyState || (
<div className={cn(
"text-center py-12",
isDark ? "text-gray-400" : "text-gray-500"
)}>
<QuestionMarkCircledIcon className={cn(
"w-12 h-12 mx-auto mb-4",
isDark ? "text-gray-700" : "text-gray-300"
)} />
<Typography
variant="h6"
weight="semibold"
className={cn("mb-2", isDark ? "text-white" : "text-gray-900")}
>
No questions found
</Typography>
<Typography
variant="body-small"
className={isDark ? "text-gray-400" : "text-gray-500"}
>
{searchQuery
? `No results for "${searchQuery}"`
: "No FAQs available in this category"
}
</Typography>
{searchQuery && (
<Button
variant="outline"
size="sm"
onClick={handleClearSearch}
className="mt-4 cursor-pointer"
>
Clear search
</Button>
)}
</div>
)
)}
</Accordion>
);
// Layout variants remain the same but with theme-aware text colors...
// [Keep all the existing layout variant code - it will now use the theme-aware components above]
switch (layoutVariant) {
case 'split-left':
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
<div className="space-y-6">
<div className="sticky top-24">
<Typography
variant="h2"
weight="bold"
className={cn(
"text-4xl md:text-5xl mb-4",
isDark ? "text-white" : "text-gray-900"
)}
>
{title}
</Typography>
<Typography
variant="lead"
className={cn(
"text-lg mb-6",
isDark ? "text-gray-400" : "text-gray-500"
)}
>
{description}
</Typography>
{enableContactSection && (
<FAQContactSection
email={contactEmail}
phone={contactPhone}
hasChat={contactChat}
scheduleUrl={scheduleUrl}
onContact={onContact}
theme={effectiveTheme}
/>
)}
</div>
</div>
<div>
{enableSearch && (
<div className="mb-6">
{customSearch || (
<FAQSearch
value={searchQuery}
onChange={setSearchQuery}
onClear={handleClearSearch}
disabled={isSearching}
theme={effectiveTheme}
/>
)}
</div>
)}
{enableCategories && categoriesWithCounts.length > 0 && (
<div className="mb-6">
{customCategoryFilter || (
<FAQCategoryTabs
categories={categoriesWithCounts}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
theme={effectiveTheme}
/>
)}
</div>
)}
{faqList}
</div>
</div>
);
case 'split-right':
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
<div>
{enableSearch && (
<div className="mb-6">
{customSearch || (
<FAQSearch
value={searchQuery}
onChange={setSearchQuery}
onClear={handleClearSearch}
disabled={isSearching}
theme={effectiveTheme}
/>
)}
</div>
)}
{enableCategories && categoriesWithCounts.length > 0 && (
<div className="mb-6">
{customCategoryFilter || (
<FAQCategoryTabs
categories={categoriesWithCounts}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
theme={effectiveTheme}
/>
)}
</div>
)}
{faqList}
</div>
<div className="space-y-6">
<div className="sticky top-24">
<Typography
variant="h2"
weight="bold"
className={cn(
"text-4xl md:text-5xl mb-4",
isDark ? "text-white" : "text-gray-900"
)}
>
{title}
</Typography>
<Typography
variant="lead"
className={cn(
"text-lg mb-6",
isDark ? "text-gray-400" : "text-gray-500"
)}
>
{description}
</Typography>
{enableContactSection && (
<FAQContactSection
email={contactEmail}
phone={contactPhone}
hasChat={contactChat}
scheduleUrl={scheduleUrl}
onContact={onContact}
theme={effectiveTheme}
/>
)}
</div>
</div>
</div>
);
case 'contact-sidebar':
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
{customHeader || (
<div className="mb-6">
<Typography
variant="h2"
weight="bold"
className={cn(
"text-3xl md:text-4xl mb-2",
isDark ? "text-white" : "text-gray-900"
)}
>
{title}
</Typography>
<Typography
variant="body"
className={isDark ? "text-gray-400" : "text-gray-500"}
>
{description}
</Typography>
</div>
)}
{enableSearch && (
<div className="mb-6">
{customSearch || (
<FAQSearch
value={searchQuery}
onChange={setSearchQuery}
onClear={handleClearSearch}
disabled={isSearching}
theme={effectiveTheme}
/>
)}
</div>
)}
{enableCategories && categoriesWithCounts.length > 0 && (
<div className="mb-6">
{customCategoryFilter || (
<FAQCategoryTabs
categories={categoriesWithCounts}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
theme={effectiveTheme}
/>
)}
</div>
)}
{faqList}
</div>
<div>
{customContactSection || (
<FAQContactSection
email={contactEmail}
phone={contactPhone}
hasChat={contactChat}
scheduleUrl={scheduleUrl}
onContact={onContact}
theme={effectiveTheme}
/>
)}
</div>
</div>
);
case 'grid':
return (
<div>
{customHeader || (
<div className="text-center mb-10">
<Typography
variant="h2"
weight="bold"
className={cn(
"text-3xl md:text-4xl mb-3",
isDark ? "text-white" : "text-gray-900"
)}
>
{title}
</Typography>
<Typography
variant="body"
className={cn(
"max-w-2xl mx-auto",
isDark ? "text-gray-400" : "text-gray-500"
)}
>
{description}
</Typography>
</div>
)}
{enableSearch && (
<div className="max-w-xl mx-auto mb-8">
{customSearch || (
<FAQSearch
value={searchQuery}
onChange={setSearchQuery}
onClear={handleClearSearch}
disabled={isSearching}
theme={effectiveTheme}
/>
)}
</div>
)}
<Accordion
value={openItems}
onValueChange={setOpenItems}
enableSingleOpen={enableSingleOpen}
variant="card"
animationVariant={animationVariant}
iconStyle={iconStyle}
theme={effectiveTheme}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{filteredItems.length > 0 ? (
filteredItems.map((item) => (
<AccordionItem key={item.id} id={item.id}>
<AccordionSummary id={item.id}>
<AccordionTitle icon={item.icon} category={item.category}>
{item.question}
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id={item.id}>
<Typography
variant="body-small"
className={cn(
"leading-relaxed",
isDark ? "text-gray-400" : "text-gray-600"
)}
>
{item.answer}
</Typography>
{item.link && (
<AccordionLink href={item.link.url}>
{item.link.text}
</AccordionLink>
)}
</AccordionDetails>
</AccordionItem>
))
) : (
<div className="col-span-2">
{customEmptyState || (
<div className={cn(
"text-center py-12",
isDark ? "text-gray-400" : "text-gray-500"
)}>
<QuestionMarkCircledIcon className={cn(
"w-12 h-12 mx-auto mb-4",
isDark ? "text-gray-700" : "text-gray-300"
)} />
<Typography
variant="h6"
weight="semibold"
className={cn("mb-2", isDark ? "text-white" : "text-gray-900")}
>
No questions found
</Typography>
<Typography
variant="body-small"
className={isDark ? "text-gray-400" : "text-gray-500"}
>
{searchQuery
? `No results for "${searchQuery}"`
: "No FAQs available"
}
</Typography>
</div>
)}
</div>
)}
</div>
</Accordion>
</div>
);
case 'minimal-list':
return (
<div className="max-w-2xl mx-auto divide-y divide-border/30">
{customHeader || (
<div className="text-center mb-10">
<Typography
variant="h1"
weight="bold"
className={cn(
"text-4xl md:text-5xl mb-4",
isDark ? "text-white" : "text-gray-900"
)}
>
{title}
</Typography>
<Typography
variant="lead"
className={cn(
"text-lg",
isDark ? "text-gray-400" : "text-gray-500"
)}
>
{description}
</Typography>
</div>
)}
{enableSearch && (
<div className="max-w-md mx-auto mb-8">
{customSearch || (
<FAQSearch
value={searchQuery}
onChange={setSearchQuery}
onClear={handleClearSearch}
disabled={isSearching}
theme={effectiveTheme}
/>
)}
</div>
)}
{faqList}
{enableContactSection && (
<div className="mt-10 text-center">
<Typography
variant="body"
className={cn("mb-4", isDark ? "text-gray-400" : "text-gray-500")}
>
{customContactSection || "Still have questions? We're here to help."}
</Typography>
<Button
variant="outline"
onClick={() => onContact?.('email')}
className={cn(
"cursor-pointer",
isDark && "border-gray-700 text-white hover:bg-gray-800"
)}
>
<EnvelopeClosedIcon className="w-4 h-4 mr-2" />
Contact support
</Button>
</div>
)}
</div>
);
case 'featured':
return (
<div className={cn(
"relative overflow-hidden rounded-2xl p-8 md:p-12",
themeVariant === 'ocean' && "bg-gradient-to-br from-[#0A1929] via-[#0A1A2F] to-[#0C1E3A]",
themeVariant === 'forest' && "bg-gradient-to-br from-[#0A1F1A] via-[#0C231E] to-[#0E2822]",
themeVariant === 'sunset' && "bg-gradient-to-br from-[#1A0F1F] via-[#201524] to-[#2A1A28]",
isDark && "text-white"
)}>
<div className="relative z-10">
{customHeader || (
<div className="text-center mb-10">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-sm text-white mb-6">
<RocketIcon className="w-4 h-4" />
<span className="text-sm font-medium">FAQ</span>
</div>
<Typography variant="h2" weight="bold" className="text-4xl md:text-5xl text-white mb-4">
{title}
</Typography>
<Typography variant="lead" className="text-white/80 max-w-2xl mx-auto">
{description}
</Typography>
</div>
)}
{enableStats && <FAQStats stats={stats} theme="dark" />}
{enableSearch && (
<div className="max-w-xl mx-auto mb-8">
{customSearch || (
<FAQSearch
value={searchQuery}
onChange={setSearchQuery}
onClear={handleClearSearch}
disabled={isSearching}
theme="dark"
/>
)}
</div>
)}
<Accordion
value={openItems}
onValueChange={setOpenItems}
enableSingleOpen={enableSingleOpen}
variant="glass"
animationVariant={animationVariant}
iconStyle={iconStyle}
theme="dark"
>
{filteredItems.map((item) => (
<AccordionItem key={item.id} id={item.id}>
<AccordionSummary id={item.id}>
<AccordionTitle icon={item.icon} category={item.category}>
{item.question}
</AccordionTitle>
</AccordionSummary>
<AccordionDetails id={item.id}>
<Typography
variant="body-small"
className="leading-relaxed text-white/80"
>
{item.answer}
</Typography>
{item.link && (
<AccordionLink href={item.link.url} className="text-white/90 hover:text-white">
{item.link.text}
</AccordionLink>
)}
</AccordionDetails>
</AccordionItem>
))}
</Accordion>
{enableContactSection && (
<div className="mt-10 text-center">
<Button
variant="outline"
size="lg"
onClick={() => onContact?.('schedule')}
className="text-white border-white/20 hover:bg-white/10 cursor-pointer"
>
<PaperPlaneIcon className="w-4 h-4 mr-2" />
Still have questions? Contact us
</Button>
</div>
)}
</div>
</div>
);
case 'category-tabs':
return (
<div className="max-w-4xl mx-auto">
{customHeader || (
<div className="text-center mb-10">
<Typography
variant="h2"
weight="bold"
className={cn(
"text-3xl md:text-4xl mb-3",
isDark ? "text-white" : "text-gray-900"
)}
>
{title}
</Typography>
<Typography
variant="body"
className={cn(
"max-w-2xl mx-auto",
isDark ? "text-gray-400" : "text-gray-500"
)}
>
{description}
</Typography>
</div>
)}
{enableCategories && categoriesWithCounts.length > 0 && (
<div className="flex justify-center mb-8">
{customCategoryFilter || (
<FAQCategoryTabs
categories={categoriesWithCounts}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
theme={effectiveTheme}
/>
)}
</div>
)}
{faqList}
</div>
);
default:
return (
<div className="max-w-3xl mx-auto">
{customHeader || (
<div className="text-center mb-10">
<Typography
variant="h2"
weight="bold"
className={cn(
"text-3xl md:text-4xl mb-3",
isDark ? "text-white" : "text-gray-900"
)}
>
{title}
</Typography>
<Typography
variant="body"
className={cn(
"max-w-2xl mx-auto",
isDark ? "text-gray-400" : "text-gray-500"
)}
>
{description}
</Typography>
</div>
)}
{enableSearch && (
<div className="max-w-xl mx-auto mb-8">
{customSearch || (
<FAQSearch
value={searchQuery}
onChange={setSearchQuery}
onClear={handleClearSearch}
disabled={isSearching}
theme={effectiveTheme}
/>
)}
</div>
)}
{enableCategories && categoriesWithCounts.length > 0 && (
<div className="flex justify-center mb-8">
{customCategoryFilter || (
<FAQCategoryTabs
categories={categoriesWithCounts}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
theme={effectiveTheme}
/>
)}
</div>
)}
{faqList}
</div>
);
}
};
if (isLoading) {
return (
<div className={cn("w-full", themeClass, className)}>
<div className="max-w-3xl mx-auto">
<div className="text-center mb-10">
<div className={cn(
"h-10 bg-muted rounded w-64 mx-auto mb-4 animate-pulse",
isDark ? "bg-gray-800" : "bg-gray-200"
)} />
<div className={cn(
"h-6 bg-muted rounded w-96 max-w-full mx-auto animate-pulse",
isDark ? "bg-gray-800" : "bg-gray-200"
)} />
</div>
<div className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className={cn(
"border rounded-lg p-6 animate-pulse",
isDark
? "border-gray-800 bg-gray-900"
: "border-gray-200 bg-white"
)}>
<div className="flex justify-between">
<div className={cn(
"h-5 rounded w-3/4",
isDark ? "bg-gray-800" : "bg-gray-200"
)} />
<div className={cn(
"w-5 h-5 rounded",
isDark ? "bg-gray-800" : "bg-gray-200"
)} />
</div>
</div>
))}
</div>
</div>
</div>
);
}
return (
<section className={cn(
"w-full py-12 md:py-16 lg:py-20",
themeClass,
className
)}>
<div className="container px-4 md:px-6">
{renderFAQContent()}
{customFooter}
</div>
</section>
);
};
// Export all components
export {
Accordion,
AccordionItem,
AccordionTitle,
AccordionSummary,
AccordionDetails,
AccordionLink,
FAQSearch,
FAQContactSection,
FAQCategoryTabs,
FAQStats,
FAQSection,
type FAQItem,
type FAQCategory,
type ContactOption,
};
Usage
The FAQ Accordion can be used in two ways:
- Quick Start: Using the pre-built
FAQSectioncomponent with all features - Custom Composition: Using the compound components for full control
Type Definitions
type LayoutVariant =
| 'standard'
| 'split-left'
| 'split-right'
| 'grid'
| 'minimal-list'
| 'contact-sidebar'
| 'category-tabs'
| 'featured';
type ThemeVariant =
| 'light'
| 'dark'
| 'midnight'
| 'charcoal'
| 'ocean'
| 'forest'
| 'sunset';
type AccordionVariant =
| 'default'
| 'card'
| 'bordered'
| 'minimal'
| 'glass'
| 'gradient';
type AnimationVariant =
| 'fade'
| 'slide'
| 'scale'
| 'flip'
| 'reveal';
type type IconStyle =
| 'chevron'
| 'plus-minus'
| 'arrow'
| 'checkmark';
interface FAQItem {
id: string;
question: string;
answer: string;
category?: string;
icon?: React.ReactNode;
link?: {
text: string;
url: string;
};
}
interface FAQCategory {
id: string;
name: string;
count?: number;
icon?: React.ReactNode;
description?: string;
}
interface StatItem {
label: string;
value: string;
icon: React.ReactNode;
}
interface ContactOption {
id: string;
type: 'email' | 'chat' | 'phone' | 'schedule';
label: string;
value: string;
icon: React.ReactNode;
action?: () => void;
}
Props
FAQSection Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | FAQItem[] | Required | Array of FAQ items to display |
categories? | FAQCategory[] | - | Array of categories for filtering |
title? | string | "Frequently asked questions" | Section title |
description? | string | "We're happy to answer your questions" | Section description |
icon? | React.ReactNode | - | Optional icon for the header |
layoutVariant? | LayoutVariant | "standard" | Layout style |
enableSearch? | boolean | false | Enable search functionality |
enableCategories? | boolean | false | Enable category filtering |
enableSingleOpen? | boolean | false | Allow only one item open at a time |
enableDeepLinking? | boolean | false | Enable URL deep linking for FAQ items |
enableContactSection? | boolean | false | Show contact section |
enableStats? | boolean | false | Show statistics cards |
variant? | AccordionVariant | "default" | Accordion item style |
themeVariant? | ThemeVariant | "light" | Theme color scheme |
animationVariant? | AnimationVariant | "slide" | Expand/collapse animation |
iconStyle? | IconStyle | "chevron" | Expand/collapse icon style |
inputVariant? | string | "clean" | Search input style variant |
buttonVariant? | string | "default" | Button style variant |
customHeader? | React.ReactNode | - | Custom header content |
customEmptyState? | React.ReactNode | - | Custom empty state content |
customSearch? | React.ReactNode | - | Custom search component |
customCategoryFilter? | React.ReactNode | - | Custom category filter component |
customContactSection? | React.ReactNode | - | Custom contact section component |
customFooter? | React.ReactNode | - | Custom footer content |
onItemToggle? | (item: FAQItem, isOpen: boolean) => void | - | Callback when item toggles |
onSearch? | (query: string) => void | - | Callback when search changes |
onContact? | (type: string) => void | - | Callback when contact option clicked |
isLoading? | boolean | false | Show loading skeleton |
isSearching? | boolean | false | Show searching state |
contactEmail? | string | "support@example.com" | Contact email |
contactPhone? | string | "+1 (555) 123-4567" | Contact phone |
contactChat? | boolean | true | Enable chat contact option |
scheduleUrl? | string | "/schedule" | URL for scheduling |
stats? | StatItem[] | Default stats Statistics data | |
darkMode? | boolean | false | Force dark mode |
className? | string | - | Additional CSS classes |
Accordion Props (Compound Component)
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | Required | Accordion items |
defaultValue? | string[] | [] | Initially open items |
value? | string[] | - | Controlled open items |
onValueChange? | (value: string[]) => void | - | Callback when open items change |
enableSingleOpen? | boolean | false | Allow only one open item |
variant? | AccordionVariant | "default" | Visual style |
animationVariant? | AnimationVariant | "slide" | Animation style |
iconStyle? | IconStyle | "chevron" | Icon style |
theme? | ThemeVariant | "light" | Theme |
className? | string | - | Additional CSS classes |
AccordionItem Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | Required | Unique identifier |
children | React.ReactNode | Required | Item content |
disabled? | boolean | false | Disable the item |
className? | string | - | Additional CSS classes |
AccordionSummary Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | Required | ID of the parent item |
children | React.ReactNode | Required | Summary content |
className? | string | - | Additional CSS classes |
AccordionTitle Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | Required | Title text/content |
icon? | React.ReactNode | - | Optional icon |
category? | string | - | Category badge text |
className? | string | - | Additional CSS classes |
AccordionDetails Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | Required | ID of the parent item |
children | React.ReactNode | Required | Details content |
className? | string | - | Additional CSS classes |
AccordionLink Props
| Prop | Type | Default | Description |
|---|---|---|---|
href | string | Required | Link URL |
children | React.ReactNode | Required | Link text/content |
className? | string | - | Additional CSS classes |