Call to Action
The CTABanner component is a flexible, full-width call-to-action section designed to capture user attention and drive conversions. It features multiple layout options, visual variants, and advanced animation capabilities.
Centered Layout
- Preview
- Code
Let's Build Something Amazing
Join thousands of satisfied customers who have transformed their business with our platform. Get started today and see the difference.
import { CTABanner, CTABannerContent } from '@ignix-ui/calltoaction';
<CTABanner
variant="dark"
layout="centered"
contentAlign="center"
>
<CTABannerContent>
<>
<CTABannerHeading>
Let's Build Something Amazing
</CTABannerHeading>
<CTABannerSubheading>
Join thousands of satisfied customers who have transformed their business with our platform.
Get started today and see the difference.
</CTABannerSubheading>
<CTABannerActions>
<CTABannerButton
label="Get Started"
variant="primary"
icon={Zap}
/>
<CTABannerButton
label="Learn More"
variant="outline"
/>
</CTABannerActions>
</>
</CTABannerContent>
</CTABanner>
Split Layout
- Preview
- Code
Let's Build Something Amazing
Join thousands of satisfied customers who have transformed their business with our platform. Get started today and see the difference.
import { CTABanner, CTABannerContent } from '@ignix-ui/calltoaction';
<CTABanner
variant="dark"
layout="split"
contentAlign="left"
imagePosition="right"
imageVariant="default"
>
<CTABannerContent>
<>
<CTABannerHeading>
Let's Build Something Amazing
</CTABannerHeading>
<CTABannerSubheading>
Join thousands of satisfied customers who have transformed their business with our platform.
Get started today and see the difference.
</CTABannerSubheading>
<CTABannerActions>
<CTABannerButton
label="Get Started"
variant="primary"
icon={Zap}
/>
<CTABannerButton
label="Learn More"
variant="outline"
/>
</CTABannerActions>
</>
</CTABannerContent>
<CTABannerImage
src="https://images.unsplash.com/photo-1552664730-d307ca884978?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&q=80"
alt="Team collaboration"
className="h-[420px] lg:h-[480px]"
/>
</CTABanner>
Background Image Layout
- Preview
- Code
Let's Build Something Amazing
Join thousands of satisfied customers who have transformed their business with our platform. Get started today and see the difference.
import { CTABanner, CTABannerContent } from '@ignix-ui/calltoaction';
<CTABanner
variant="dark"
layout="centered"
contentAlign="center"
backgroundType="image"
backgroundImage="https://images.unsplash.com/photo-1663427929868-3941f957bb36?q=80&w=1332&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
>
{/* Optional overlay for better contrast */}
<div className="absolute inset-0 bg-black/40" />
<CTABannerContent className="relative z-10">
<>
<CTABannerHeading>
Let's Build Something Amazing
</CTABannerHeading>
<CTABannerSubheading>
Join thousands of satisfied customers who have transformed their business with our platform.
Get started today and see the difference.
</CTABannerSubheading>
<CTABannerActions>
<CTABannerButton
label="Get Started"
variant="primary"
icon={Zap}
/>
<CTABannerButton
label="Learn More"
variant="outline"
/>
</CTABannerActions>
</>
</CTABannerContent>
</CTABanner>
Gradient Background Layout
- Preview
- Code
Let's Build Something Amazing
Join thousands of satisfied customers who have transformed their business with our platform. Get started today and see the difference.
import { CTABanner, CTABannerContent } from '@ignix-ui/calltoaction';
<CTABanner
variant="dark"
layout="centered"
contentAlign="center"
backgroundType="image"
backgroundImage="https://images.unsplash.com/photo-1556761175-b413da4baf72?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3"
>
{/* Gradient overlay */}
<div
className="absolute inset-0"
style={{
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8) 0%, rgba(139, 92, 246, 0.8) 100%)',
}}
/>
<CTABannerContent className="relative z-10">
<>
<CTABannerHeading>
Let's Build Something Amazing
</CTABannerHeading>
<CTABannerSubheading>
Join thousands of satisfied customers who have transformed their business with our platform.
Get started today and see the difference.
</CTABannerSubheading>
<CTABannerActions>
<CTABannerButton
label="Get Started"
variant="primary"
icon={Zap}
/>
<CTABannerButton
label="Learn More"
variant="outline"
/>
</CTABannerActions>
</>
</CTABannerContent>
</CTABanner>
Installation
- CLI
- MANUAL
ignix add component calltoaction
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { cva, type VariantProps } from 'class-variance-authority';
import {
ArrowRight,
Sparkles,
CheckCircle, Mail, Loader2,
User, Building, Phone, MessageSquare, Send, HelpCircle
} from 'lucide-react';
import { cn } from '../../../utils/cn';
import { Button } from '@ignix-ui//button';
import { Typography } from '@ignix-ui//typography';
/* ============================================
TYPES & INTERFACES
============================================ */
interface CTAButton {
id: string;
label: string;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link';
icon?: React.ElementType;
onClick?: () => void;
href?: string;
external?: boolean;
}
interface CTABannerProps {
// Layout & Variants
variant?: VariantProps<typeof bannerVariants>['variant'];
layout?: 'centered' | 'split' | 'compact';
contentAlign?: 'left' | 'center' | 'right';
// Background & Styling
backgroundType?: 'solid' | 'gradient' | 'image';
backgroundColor?: string;
gradientFrom?: string;
gradientTo?: string;
backgroundImage?: string;
// Image Options (for split layout)
imagePosition?: 'left' | 'right';
imageVariant?: 'light' | 'dark' | 'default';
// Theme
theme?: 'light' | 'dark';
forceTheme?: boolean;
// Animation
animate?: boolean;
animationDelay?: number;
animationType?: 'fade' | 'slide' | 'scale';
// Spacing
padding?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
// Children
children: React.ReactNode;
// Accessibility
ariaLabel?: string;
role?: string;
}
interface CTAContextType {
theme: 'light' | 'dark';
layout: 'centered' | 'split' | 'compact';
contentAlign: 'left' | 'center' | 'right';
variant: string;
imagePosition: 'left' | 'right';
isVisible: boolean;
animationDelay: number;
animationType: 'fade' | 'slide' | 'scale';
handleButtonClick: (button: CTAButton) => void;
}
const CTAContext = React.createContext<CTAContextType | undefined>(undefined);
const useCTA = () => {
const context = React.useContext(CTAContext);
if (!context) {
throw new Error('CTA components must be used within CTABanner');
}
return context;
};
/* ============================================
BASIC CHILD COMPONENTS
============================================ */
/**
* Heading component for the CTA banner. Renders the main title with animations.
*
* @component
* @example
* ```tsx
* <CTABannerHeading>
* Ready to get started?
* </CTABannerHeading>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Heading text content
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Animated heading component
*/
export const CTABannerHeading: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { theme, layout, isVisible, animationDelay, animationType } = useCTA();
return (
<motion.div
initial={animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
animate={isVisible ? {
opacity: 1,
y: 0,
scale: 1
} : animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.6, delay: animationDelay }}
>
<Typography
variant={layout === 'compact' ? "h3" : "h2"}
weight="bold"
className={cn(
"mb-4 md:mb-6",
theme === 'dark' ? 'text-white' : 'text-gray-900',
layout === 'compact' && 'text-2xl md:text-3xl',
className
)}
>
{children}
</Typography>
</motion.div>
);
};
/**
* Subheading component for the CTA banner. Provides supporting text with animations.
*
* @component
* @example
* ```tsx
* <CTABannerSubheading>
* Join thousands of satisfied customers today.
* </CTABannerSubheading>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Subheading text content
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Animated subheading component
*/
export const CTABannerSubheading: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { theme, layout, contentAlign, isVisible, animationDelay, animationType } = useCTA();
return (
<motion.div
initial={animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
animate={isVisible ? {
opacity: 1,
y: 0,
scale: 1
} : animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.6, delay: animationDelay + 0.1 }}
>
<Typography
variant={layout === 'compact' ? "body" : "lead"}
className={cn(
"mb-6 md:mb-8",
theme === 'dark' ? 'text-gray-300' : 'text-gray-600',
layout === 'compact' ? 'text-base md:text-lg' : 'text-lg md:text-xl',
contentAlign === 'center' && 'mx-auto',
contentAlign === 'right' && 'ml-auto',
contentAlign === 'left' && 'mr-auto',
className
)}
>
{children}
</Typography>
</motion.div>
);
};
/**
* Container for CTA action buttons. Handles layout and animations for button groups.
*
* @component
* @example
* ```tsx
* <CTABannerActions>
* <CTABannerButton label="Get Started" />
* <CTABannerButton label="Learn More" variant="outline" />
* </CTABannerActions>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Button components
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Animated container for action buttons
*/
export const CTABannerActions: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { contentAlign, isVisible, animationDelay } = useCTA();
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.5, delay: animationDelay + 0.3 }}
className={cn(
"flex flex-wrap gap-3 md:gap-4",
contentAlign === 'center' && 'justify-center',
contentAlign === 'right' && 'justify-start',
contentAlign === 'left' && 'justify-start',
className
)}
>
{children}
</motion.div>
);
};
/**
* Button component for CTA actions. Supports multiple variants, icons, and link behavior.
*
* @component
* @example
* ```tsx
* <CTABannerButton
* label="Get Started"
* variant="primary"
* icon={ArrowRight}
* onClick={() => console.log('clicked')}
* />
* ```
*
* @param {Object} props - Component props
* @param {string} props.label - Button text
* @param {('primary'|'secondary'|'outline'|'ghost'|'link')} [props.variant='primary'] - Button style variant
* @param {React.ElementType} [props.icon] - Icon component to display
* @param {() => void} [props.onClick] - Click handler
* @param {string} [props.href] - Link URL (if button should behave as a link)
* @param {boolean} [props.external] - Whether the link opens in a new tab
* @param {('sm'|'md'|'lg')} [props.size] - Button size
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Styled button component
*/
export const CTABannerButton: React.FC<{
label: string;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'link';
icon?: React.ElementType;
onClick?: () => void;
href?: string;
external?: boolean;
size?: 'sm' | 'md' | 'lg';
className?: string;
}> = ({ label, variant = 'primary', icon: Icon, onClick, href, external, size, className }) => {
const { theme, layout, handleButtonClick } = useCTA();
const button: CTAButton = {
id: `button-${label.toLowerCase().replace(/\s+/g, '-')}`,
label,
variant,
icon: Icon,
onClick,
href,
external,
};
return (
<Button
variant={variant}
size={size || (layout === 'compact' ? "md" : "lg")}
onClick={() => handleButtonClick(button)}
className={cn(
"group cursor-pointer",
theme === 'dark' && variant === 'outline' &&
'border-gray-300 text-gray-300 hover:bg-gray-300 hover:text-gray-900',
theme === 'light' && variant === 'outline' &&
'border-gray-700 text-gray-700 hover:bg-gray-700 hover:text-white',
className
)}
animationVariant="scaleUp"
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{label}
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
);
};
/**
* Image component for split layout CTA banners. Only renders when layout is 'split'.
*
* @component
* @example
* ```tsx
* <CTABannerImage
* src="/images/cta-image.jpg"
* alt="Feature illustration"
* variant="light"
* />
* ```
*
* @param {Object} props - Component props
* @param {string} props.src - Image source URL
* @param {string} [props.alt="CTA Visual"] - Alt text for the image
* @param {string} [props.className] - Additional CSS classes
* @param {('light'|'dark'|'default')} [props.variant] - Image style variant
* @returns {JSX.Element|null} Animated image component or null if layout not split
*/
export const CTABannerImage: React.FC<{
src: string;
alt?: string;
className?: string;
variant?: 'light' | 'dark' | 'default';
}> = ({ src, alt = "CTA Visual", className, variant }) => {
const { layout, theme, isVisible, animationDelay, imageVariant: contextVariant } = useCTA();
if (layout !== 'split') return null;
const resolvedVariant =
variant || contextVariant || (theme === 'dark' ? 'dark' : 'light');
return (
<motion.div
initial={{ opacity: 0, x: 40 }}
animate={isVisible ? { opacity: 1, x: 0 } : { opacity: 0, x: 40 }}
transition={{ duration: 0.7, delay: animationDelay + 0.2 }}
className={imageVariants({ variant: resolvedVariant })}
>
<div className={cn(
"w-full h-[360px] sm:h-[420px] lg:h-[480px] rounded-2xl overflow-hidden",
className
)}>
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
/>
</div>
</motion.div>
);
};
/**
* Content wrapper component that handles layout positioning and animations for CTA content.
*
* @component
* @example
* ```tsx
* <CTABannerContent>
* <CTABannerHeading>Title</CTABannerHeading>
* <CTABannerSubheading>Description</CTABannerSubheading>
* </CTABannerContent>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Content elements
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Animated content container
*/
export const CTABannerContent: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { layout, contentAlign, isVisible, animationType, animationDelay } = useCTA();
const contentAnimation = {
fade: {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.6, delay: animationDelay }
},
slide: {
initial: { opacity: 0, y: 40 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.8, delay: animationDelay, ease: "easeOut" }
},
scale: {
initial: { opacity: 0, scale: 0.95 },
animate: { opacity: 1, scale: 1 },
transition: { duration: 0.7, delay: animationDelay, ease: "backOut" }
}
}[animationType];
return (
<motion.div
{...contentAnimation}
animate={isVisible ? contentAnimation.animate : contentAnimation.initial}
className={cn(
layout === 'split' && 'lg:w-1/2',
contentVariants({ align: contentAlign, layout }),
className
)}
>
{children}
</motion.div>
);
};
/* ============================================
VARIANTS
============================================ */
const bannerVariants = cva(
"w-full overflow-hidden transition-all duration-300",
{
variants: {
variant: {
default: "bg-background text-foreground",
primary: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
accent: "bg-accent text-accent-foreground",
muted: "bg-muted text-muted-foreground",
gradient: "bg-gradient-to-r from-primary/90 to-accent/90 text-primary-foreground",
glass: "bg-background/80 backdrop-blur-md border border-border",
dark: "bg-gray-950 text-gray-50",
light: "bg-gray-50 text-gray-900",
},
padding: {
sm: "py-8 md:py-12",
md: "py-12 md:py-16",
lg: "py-16 md:py-20",
xl: "py-20 md:py-24",
'2xl': "py-24 md:py-32",
},
layout: {
centered: "text-center",
split: "",
compact: "py-8 md:py-12",
}
},
defaultVariants: {
variant: "default",
padding: "lg",
layout: "centered",
},
}
);
const containerVariants = cva(
"container mx-auto px-4 sm:px-6 lg:px-8",
{
variants: {
layout: {
centered: "max-w-3xl",
split: "max-w-7xl",
compact: "max-w-2xl",
},
},
defaultVariants: {
layout: "centered",
},
}
);
const contentVariants = cva(
"transition-all duration-300",
{
variants: {
align: {
left: "text-left",
center: "text-center mx-auto",
right: "text-right ml-auto",
},
layout: {
centered: "w-full",
split: "lg:w-1/2",
compact: "w-full",
},
},
defaultVariants: {
align: "center",
layout: "centered",
},
}
);
const imageVariants = cva(
"transition-all duration-500 rounded-2xl overflow-hidden shadow-2xl blur-9xl",
{
variants: {
variant: {
light: "brightness-110 contrast-90 saturate-90",
dark: "brightness-90 contrast-110 saturate-110",
default: "",
},
layout: {
split: "",
centered: "hidden",
compact: "hidden",
},
},
defaultVariants: {
variant: "default",
layout: "split",
},
}
);
/* ============================================
FORM-SPECIFIC CHILD COMPONENTS
============================================ */
/**
* Heading component specifically for demo request forms.
*
* @component
* @example
* ```tsx
* <DemoFormHeading>
* Request a Demo
* </DemoFormHeading>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Heading text
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Styled heading for demo forms
*/
export const DemoFormHeading: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { theme } = useCTA();
return (
<Typography
variant="h3"
weight="semibold"
className={cn(
"mb-3",
theme === 'dark' ? 'text-white' : 'text-gray-900',
className
)}
>
{children}
</Typography>
);
};
/**
* Subheading component specifically for demo request forms.
*
* @component
* @example
* ```tsx
* <DemoFormSubheading>
* Fill out the form below and we'll contact you shortly.
* </DemoFormSubheading>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Subheading text
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Styled subheading for demo forms
*/
export const DemoFormSubheading: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { theme } = useCTA();
return (
<Typography
variant="body"
className={cn(
"mb-6",
theme === 'dark' ? 'text-gray-300' : 'text-gray-600',
className
)}
>
{children}
</Typography>
);
};
/**
* Heading component specifically for contact forms.
*
* @component
* @example
* ```tsx
* <ContactFormHeading>
* Get in Touch
* </ContactFormHeading>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Heading text
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Styled heading for contact forms
*/
export const ContactFormHeading: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { theme } = useCTA();
return (
<Typography
variant="h3"
weight="semibold"
className={cn(
"mb-4",
theme === 'dark' ? 'text-white' : 'text-gray-900',
className
)}
>
{children}
</Typography>
);
};
/**
* Subheading component specifically for contact forms.
*
* @component
* @example
* ```tsx
* <ContactFormSubheading>
* We'd love to hear from you. Send us a message and we'll respond as soon as possible.
* </ContactFormSubheading>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Subheading text
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Styled subheading for contact forms
*/
export const ContactFormSubheading: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { theme } = useCTA();
return (
<Typography
variant="body"
className={cn(
"mb-6",
theme === 'dark' ? 'text-gray-300' : 'text-gray-600',
className
)}
>
{children}
</Typography>
);
};
/**
* Heading component specifically for newsletter signup forms.
*
* @component
* @example
* ```tsx
* <NewsletterHeading>
* Subscribe to Our Newsletter
* </NewsletterHeading>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Heading text
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Styled heading for newsletter forms
*/
export const NewsletterHeading: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { theme } = useCTA();
return (
<Typography
variant="h3"
weight="semibold"
className={cn(
"mb-2",
theme === 'dark' ? 'text-white' : 'text-gray-900',
className
)}
>
{children}
</Typography>
);
};
/**
* Subheading component specifically for newsletter signup forms.
*
* @component
* @example
* ```tsx
* <NewsletterSubheading>
* Get the latest updates and offers delivered to your inbox.
* </NewsletterSubheading>
* ```
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Subheading text
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Styled subheading for newsletter forms
*/
export const NewsletterSubheading: React.FC<{
children: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const { theme } = useCTA();
return (
<Typography
variant="body"
className={cn(
"mb-4",
theme === 'dark' ? 'text-gray-300' : 'text-gray-600',
className
)}
>
{children}
</Typography>
);
};
/* ============================================
FORM DATA TYPES
============================================ */
export interface DemoRequestData {
name: string;
email: string;
company: string;
phone: string;
}
export interface ContactFormData {
name: string;
email: string;
subject: string;
message: string;
}
/* ============================================
DEMO REQUEST COMPONENT (Accepts Children)
============================================ */
/**
* Demo request form component for collecting user information to schedule a product demo.
* Supports comprehensive validation, loading states, success/error handling, and accepts
* child components for custom headings and content.
*
* @component
* @example
* ```tsx
* <CTABannerDemoRequest
* onSubmit={handleDemoRequest}
* requireCompany={true}
* layout="two-column"
* >
* <DemoFormHeading>Request a Demo</DemoFormHeading>
* <DemoFormSubheading>See our platform in action</DemoFormSubheading>
* </CTABannerDemoRequest>
* ```
*
* @param {Object} props - Component props
* @param {string} [props.nameLabel="Full Name"] - Label for name field
* @param {string} [props.namePlaceholder="Enter your full name"] - Placeholder for name field
* @param {string} [props.emailLabel="Work Email"] - Label for email field
* @param {string} [props.emailPlaceholder="you@company.com"] - Placeholder for email field
* @param {string} [props.companyLabel="Company"] - Label for company field
* @param {string} [props.companyPlaceholder="Your company name"] - Placeholder for company field
* @param {string} [props.phoneLabel="Phone Number"] - Label for phone field
* @param {string} [props.phonePlaceholder="(123) 456-7890"] - Placeholder for phone field
* @param {string} [props.submitText="Request Demo"] - Submit button text
* @param {string} [props.successMessage] - Success message after submission
* @param {boolean} [props.requireCompany=false] - Whether company field is required
* @param {boolean} [props.requirePhone=false] - Whether phone field is required
* @param {('single'|'two-column')} [props.layout='single'] - Form layout style
* @param {(data: DemoRequestData) => Promise<void> | void} [props.onSubmit] - Submit handler
* @param {React.ReactNode} [props.children] - Optional child components for headings
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Complete demo request form with validation and animations
*/
export const CTABannerDemoRequest: React.FC<{
// Form configuration
nameLabel?: string;
namePlaceholder?: string;
emailLabel?: string;
emailPlaceholder?: string;
companyLabel?: string;
companyPlaceholder?: string;
phoneLabel?: string;
phonePlaceholder?: string;
submitText?: string;
successMessage?: string;
// Validation
requireCompany?: boolean;
requirePhone?: boolean;
// Layout
layout?: 'single' | 'two-column';
// API/Handler
onSubmit?: (data: DemoRequestData) => Promise<void> | void;
// Children
children?: React.ReactNode;
className?: string;
}> = ({
nameLabel = "Full Name",
namePlaceholder = "Enter your full name",
emailLabel = "Work Email",
emailPlaceholder = "you@company.com",
companyLabel = "Company",
companyPlaceholder = "Your company name",
phoneLabel = "Phone Number",
phonePlaceholder = "(123) 456-7890",
submitText = "Request Demo",
successMessage = "Thank you! We've received your demo request. Our team will contact you within 24 hours.",
requireCompany = false,
requirePhone = false,
layout = 'single',
onSubmit,
children,
className
}) => {
const { theme, isVisible, animationDelay, animationType } = useCTA();
const [formData, setFormData] = useState<DemoRequestData>({
name: '',
email: '',
company: '',
phone: ''
});
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errors, setErrors] = useState<Partial<Record<keyof DemoRequestData, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof DemoRequestData, boolean>>>({});
const validateField = (name: keyof DemoRequestData, value: string): string => {
switch (name) {
case 'name': {
if (!value.trim()) return 'Name is required';
if (value.length < 2) return 'Name must be at least 2 characters';
return '';
}
case 'email': {
if (!value.trim()) return 'Email is required';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) return 'Please enter a valid email address';
return '';
}
case 'company': {
if (requireCompany && !value.trim()) return 'Company is required';
return '';
}
case 'phone': {
if (requirePhone && !value.trim()) return 'Phone is required';
if (value && !/^[\d\s\-+()]+$/.test(value)) return 'Please enter a valid phone number';
if (value && value.replace(/\D/g, '').length < 10) return 'Phone number must be at least 10 digits';
return '';
}
default:
return '';
}
};
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof DemoRequestData, string>> = {};
Object.keys(formData).forEach(key => {
const fieldName = key as keyof DemoRequestData;
const error = validateField(fieldName, formData[fieldName]);
if (error) {
newErrors[fieldName] = error;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleChange = (field: keyof DemoRequestData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
const error = validateField(field, value);
setErrors(prev => ({
...prev,
[field]: error
}));
}
};
const handleBlur = (field: keyof DemoRequestData) => {
setTouched(prev => ({ ...prev, [field]: true }));
const error = validateField(field, formData[field]);
setErrors(prev => ({
...prev,
[field]: error
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Mark all fields as touched
setTouched({
name: true,
email: true,
company: true,
phone: true
});
if (!validateForm()) {
return;
}
setStatus('loading');
try {
if (onSubmit) {
await onSubmit(formData);
}
// Mock API call for demo
await new Promise(resolve => setTimeout(resolve, 1500));
setStatus('success');
setFormData({ name: '', email: '', company: '', phone: '' });
setTouched({});
} catch (err) {
setStatus('error');
alert('Demo request submission error: ' + err);
}
};
const isSubmitDisabled = status === 'loading' || status === 'success';
return (
<motion.div
initial={animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
animate={isVisible ? {
opacity: 1,
y: 0,
scale: 1
} : animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.6, delay: animationDelay + 0.4 }}
className={cn("w-full", className)}
>
{/* Children content - can include headings, subheadings, etc. */}
{children && <div className="space-y-4 mb-8">{children}</div>}
{status === 'success' ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className={cn(
"p-6 rounded-xl border-2",
theme === 'dark'
? "bg-green-900/20 border-green-800 text-green-400"
: "bg-green-50 border-green-200 text-green-800"
)}
role="alert"
>
<div className="flex items-start gap-3">
<CheckCircle className="w-6 h-6 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold">Demo Request Submitted!</p>
<p className="mt-1">{successMessage}</p>
<button
onClick={() => setStatus('idle')}
className={cn(
"mt-4 text-sm font-medium underline",
theme === 'dark' ? 'text-green-300' : 'text-green-700'
)}
>
Request another demo
</button>
</div>
</div>
</motion.div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className={cn(
"grid gap-4 md:gap-6",
layout === 'two-column' ? 'md:grid-cols-2' : ''
)}>
{/* Name Field */}
<div className={layout === 'two-column' ? 'md:col-span-2' : ''}>
<label htmlFor="demo-name" className="block mb-2">
<Typography
variant="small"
weight="medium"
className={cn(
theme === 'dark' ? 'text-gray-300' : 'text-gray-700'
)}
>
{nameLabel} *
</Typography>
</label>
<div className="relative">
<User className={cn(
"absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5",
theme === 'dark' ? 'text-gray-400' : 'text-gray-500',
errors.name && (theme === 'dark' ? 'text-red-400' : 'text-red-500')
)} />
<input
id="demo-name"
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
onBlur={() => handleBlur('name')}
placeholder={namePlaceholder}
disabled={isSubmitDisabled}
aria-label={nameLabel}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "demo-name-error" : undefined}
className={cn(
"w-full pl-10 pr-4 py-3 rounded-lg border-2 transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
theme === 'dark'
? cn(
"bg-gray-900 border-gray-700 text-white placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.name && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.name && touched.name && "border-green-500"
)
: cn(
"bg-white border-gray-300 text-gray-900 placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.name && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.name && touched.name && "border-green-500"
),
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
/>
</div>
{errors.name && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1 text-sm text-red-500"
id="demo-name-error"
role="alert"
>
{errors.name}
</motion.p>
)}
</div>
{/* Email Field */}
<div className={layout === 'two-column' ? 'md:col-span-2' : ''}>
<label htmlFor="demo-email" className="block mb-2">
<Typography
variant="small"
weight="medium"
className={cn(
theme === 'dark' ? 'text-gray-300' : 'text-gray-700'
)}
>
{emailLabel} *
</Typography>
</label>
<div className="relative">
<Mail className={cn(
"absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5",
theme === 'dark' ? 'text-gray-400' : 'text-gray-500',
errors.email && (theme === 'dark' ? 'text-red-400' : 'text-red-500')
)} />
<input
id="demo-email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
onBlur={() => handleBlur('email')}
placeholder={emailPlaceholder}
disabled={isSubmitDisabled}
aria-label={emailLabel}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "demo-email-error" : undefined}
className={cn(
"w-full pl-10 pr-4 py-3 rounded-lg border-2 transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
theme === 'dark'
? cn(
"bg-gray-900 border-gray-700 text-white placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.email && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.email && touched.email && "border-green-500"
)
: cn(
"bg-white border-gray-300 text-gray-900 placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.email && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.email && touched.email && "border-green-500"
),
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
/>
</div>
{errors.email && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1 text-sm text-red-500"
id="demo-email-error"
role="alert"
>
{errors.email}
</motion.p>
)}
</div>
{/* Company Field */}
<div>
<label htmlFor="demo-company" className="block mb-2">
<Typography
variant="small"
weight="medium"
className={cn(
theme === 'dark' ? 'text-gray-300' : 'text-gray-700'
)}
>
{companyLabel} {requireCompany ? '*' : ''}
</Typography>
</label>
<div className="relative">
<Building className={cn(
"absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5",
theme === 'dark' ? 'text-gray-400' : 'text-gray-500',
errors.company && (theme === 'dark' ? 'text-red-400' : 'text-red-500')
)} />
<input
id="demo-company"
type="text"
value={formData.company}
onChange={(e) => handleChange('company', e.target.value)}
onBlur={() => handleBlur('company')}
placeholder={companyPlaceholder}
disabled={isSubmitDisabled}
aria-label={companyLabel}
aria-invalid={!!errors.company}
aria-describedby={errors.company ? "demo-company-error" : undefined}
className={cn(
"w-full pl-10 pr-4 py-3 rounded-lg border-2 transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
theme === 'dark'
? cn(
"bg-gray-900 border-gray-700 text-white placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.company && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.company && touched.company && "border-green-500"
)
: cn(
"bg-white border-gray-300 text-gray-900 placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.company && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.company && touched.company && "border-green-500"
),
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
/>
</div>
{errors.company && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1 text-sm text-red-500"
id="demo-company-error"
role="alert"
>
{errors.company}
</motion.p>
)}
</div>
{/* Phone Field */}
<div>
<label htmlFor="demo-phone" className="block mb-2">
<Typography
variant="small"
weight="medium"
className={cn(
theme === 'dark' ? 'text-gray-300' : 'text-gray-700'
)}
>
{phoneLabel} {requirePhone ? '*' : ''}
</Typography>
</label>
<div className="relative">
<Phone className={cn(
"absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5",
theme === 'dark' ? 'text-gray-400' : 'text-gray-500',
errors.phone && (theme === 'dark' ? 'text-red-400' : 'text-red-500')
)} />
<input
id="demo-phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
onBlur={() => handleBlur('phone')}
placeholder={phonePlaceholder}
disabled={isSubmitDisabled}
aria-label={phoneLabel}
aria-invalid={!!errors.phone}
aria-describedby={errors.phone ? "demo-phone-error" : undefined}
className={cn(
"w-full pl-10 pr-4 py-3 rounded-lg border-2 transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
theme === 'dark'
? cn(
"bg-gray-900 border-gray-700 text-white placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.phone && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.phone && touched.phone && "border-green-500"
)
: cn(
"bg-white border-gray-300 text-gray-900 placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.phone && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.phone && touched.phone && "border-green-500"
),
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
/>
</div>
{errors.phone && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1 text-sm text-red-500"
id="demo-phone-error"
role="alert"
>
{errors.phone}
</motion.p>
)}
</div>
</div>
<Button
type="submit"
variant="primary"
size="lg"
disabled={isSubmitDisabled}
className="w-full md:w-auto min-w-[160px]"
>
{status === 'loading' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<HelpCircle className="w-4 h-4 mr-2" />
{submitText}
</>
)}
</Button>
{status === 'error' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn(
"p-4 rounded-lg border",
theme === 'dark'
? "bg-red-900/20 border-red-800 text-red-400"
: "bg-red-50 border-red-200 text-red-800"
)}
role="alert"
>
<div className="flex items-center gap-2">
<span className="font-medium">Submission failed</span>
</div>
<p className="mt-1 text-sm">Please try again or contact support if the issue persists.</p>
</motion.div>
)}
</form>
)}
</motion.div>
);
};
/* ============================================
CONTACT FORM COMPONENT (Accepts Children)
============================================ */
/**
* Contact form component for collecting user messages and inquiries.
* Features comprehensive validation, character counting, and success/error states.
* Accepts child components for custom headings and content.
*
* @component
* @example
* ```tsx
* <CTABannerContactForm
* onSubmit={handleContactSubmit}
* requireSubject={true}
* maxMessageLength={1000}
* layout="vertical"
* >
* <ContactFormHeading>Contact Us</ContactFormHeading>
* <ContactFormSubheading>We're here to help</ContactFormSubheading>
* </CTABannerContactForm>
* ```
*
* @param {Object} props - Component props
* @param {string} [props.namePlaceholder="Enter your name"] - Placeholder for name field
* @param {string} [props.emailPlaceholder="you@example.com"] - Placeholder for email field
* @param {string} [props.subjectPlaceholder="How can we help you?"] - Placeholder for subject field
* @param {string} [props.messagePlaceholder="Tell us about your inquiry..."] - Placeholder for message field
* @param {string} [props.submitText="Send Message"] - Submit button text
* @param {string} [props.successMessage] - Success message after submission
* @param {boolean} [props.requireSubject=true] - Whether subject field is required
* @param {number} [props.maxMessageLength=1000] - Maximum allowed characters for message
* @param {('vertical'|'compact')} [props.layout='vertical'] - Form layout style
* @param {(data: ContactFormData) => Promise<void> | void} [props.onSubmit] - Submit handler
* @param {React.ReactNode} [props.children] - Optional child components for headings
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Complete contact form with validation and animations
*/
export const CTABannerContactForm: React.FC<{
// Form configuration
namePlaceholder?: string;
emailPlaceholder?: string;
subjectPlaceholder?: string;
messagePlaceholder?: string;
submitText?: string;
successMessage?: string;
requireSubject?: boolean;
maxMessageLength?: number;
layout?: 'vertical' | 'compact';
// API/Handler
onSubmit?: (data: ContactFormData) => Promise<void> | void;
// Children
children?: React.ReactNode;
className?: string;
}> = ({
namePlaceholder = "Enter your name",
emailPlaceholder = "you@example.com",
subjectPlaceholder = "How can we help you?",
messagePlaceholder = "Tell us about your inquiry...",
submitText = "Send Message",
successMessage = "Thank you for your message! We've received your inquiry and will get back to you within 24 hours.",
requireSubject = true,
maxMessageLength = 1000,
layout = 'vertical',
onSubmit,
children,
className
}) => {
const { theme, isVisible, animationDelay, animationType } = useCTA();
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
subject: '',
message: ''
});
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errors, setErrors] = useState<Partial<Record<keyof ContactFormData, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof ContactFormData, boolean>>>({});
const [characterCount, setCharacterCount] = useState(0);
const validateField = (name: keyof ContactFormData, value: string): string => {
switch (name) {
case 'name': {
if (!value.trim()) return 'Name is required';
if (value.length < 2) return 'Name must be at least 2 characters';
return '';
}
case 'email': {
if (!value.trim()) return 'Email is required';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) return 'Please enter a valid email address';
return '';
}
case 'subject': {
if (requireSubject && !value.trim()) return 'Subject is required';
return '';
}
case 'message': {
if (!value.trim()) return 'Message is required';
if (value.length < 10) return 'Message must be at least 10 characters';
if (maxMessageLength && value.length > maxMessageLength) return `Message must be less than ${maxMessageLength} characters`;
return '';
}
default:
return '';
}
};
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof ContactFormData, string>> = {};
Object.keys(formData).forEach(key => {
const fieldName = key as keyof ContactFormData;
const error = validateField(fieldName, formData[fieldName]);
if (error) {
newErrors[fieldName] = error;
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleChange = (field: keyof ContactFormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (field === 'message') {
setCharacterCount(value.length);
}
// Clear error when user starts typing
if (errors[field]) {
const error = validateField(field, value);
setErrors(prev => ({
...prev,
[field]: error
}));
}
};
const handleBlur = (field: keyof ContactFormData) => {
setTouched(prev => ({ ...prev, [field]: true }));
const error = validateField(field, formData[field]);
setErrors(prev => ({
...prev,
[field]: error
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Mark all fields as touched
setTouched({
name: true,
email: true,
subject: true,
message: true
});
if (!validateForm()) {
return;
}
setStatus('loading');
try {
if (onSubmit) {
await onSubmit(formData);
}
// Mock API call for demo
await new Promise(resolve => setTimeout(resolve, 1500));
setStatus('success');
setFormData({ name: '', email: '', subject: '', message: '' });
setCharacterCount(0);
setTouched({});
} catch (err) {
setStatus('error');
alert('Contact form submission error: ' + err);
}
};
const isSubmitDisabled = status === 'loading' || status === 'success';
return (
<motion.div
initial={animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
animate={isVisible ? {
opacity: 1,
y: 0,
scale: 1
} : animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.6, delay: animationDelay + 0.4 }}
className={cn("w-full", className)}
>
{/* Children content - can include headings, subheadings, etc. */}
{children && <div className="space-y-4 mb-8">{children}</div>}
{status === 'success' ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className={cn(
"p-6 rounded-xl border-2",
theme === 'dark'
? "bg-green-900/20 border-green-800 text-green-400"
: "bg-green-50 border-green-200 text-green-800"
)}
role="alert"
>
<div className="flex items-start gap-3">
<CheckCircle className="w-6 h-6 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold">Message Sent!</p>
<p className="mt-1">{successMessage}</p>
<button
onClick={() => setStatus('idle')}
className={cn(
"mt-4 text-sm font-medium underline",
theme === 'dark' ? 'text-green-300' : 'text-green-700'
)}
>
Send another message
</button>
</div>
</div>
</motion.div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className={cn(
"grid gap-4 md:gap-6",
layout === 'compact' ? 'md:grid-cols-2' : ''
)}>
{/* Name Field */}
<div>
<label htmlFor="contact-name" className="block mb-2">
<Typography
variant="small"
weight="medium"
className={cn(
theme === 'dark' ? 'text-gray-300' : 'text-gray-700'
)}
>
Name *
</Typography>
</label>
<div className="relative">
<User className={cn(
"absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5",
theme === 'dark' ? 'text-gray-400' : 'text-gray-500',
errors.name && (theme === 'dark' ? 'text-red-400' : 'text-red-500')
)} />
<input
id="contact-name"
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
onBlur={() => handleBlur('name')}
placeholder={namePlaceholder}
disabled={isSubmitDisabled}
aria-label="Name"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "contact-name-error" : undefined}
className={cn(
"w-full pl-10 pr-4 py-3 rounded-lg border-2 transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
theme === 'dark'
? cn(
"bg-gray-900 border-gray-700 text-white placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.name && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.name && touched.name && "border-green-500"
)
: cn(
"bg-white border-gray-300 text-gray-900 placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.name && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.name && touched.name && "border-green-500"
),
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
/>
</div>
{errors.name && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1 text-sm text-red-500"
id="contact-name-error"
role="alert"
>
{errors.name}
</motion.p>
)}
</div>
{/* Email Field */}
<div>
<label htmlFor="contact-email" className="block mb-2">
<Typography
variant="small"
weight="medium"
className={cn(
theme === 'dark' ? 'text-gray-300' : 'text-gray-700'
)}
>
Email *
</Typography>
</label>
<div className="relative">
<Mail className={cn(
"absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5",
theme === 'dark' ? 'text-gray-400' : 'text-gray-500',
errors.email && (theme === 'dark' ? 'text-red-400' : 'text-red-500')
)} />
<input
id="contact-email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
onBlur={() => handleBlur('email')}
placeholder={emailPlaceholder}
disabled={isSubmitDisabled}
aria-label="Email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "contact-email-error" : undefined}
className={cn(
"w-full pl-10 pr-4 py-3 rounded-lg border-2 transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
theme === 'dark'
? cn(
"bg-gray-900 border-gray-700 text-white placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.email && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.email && touched.email && "border-green-500"
)
: cn(
"bg-white border-gray-300 text-gray-900 placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.email && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.email && touched.email && "border-green-500"
),
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
/>
</div>
{errors.email && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1 text-sm text-red-500"
id="contact-email-error"
role="alert"
>
{errors.email}
</motion.p>
)}
</div>
{/* Subject Field - Full width */}
<div className={layout === 'compact' ? 'md:col-span-2' : ''}>
<label htmlFor="contact-subject" className="block mb-2">
<Typography
variant="small"
weight="medium"
className={cn(
theme === 'dark' ? 'text-gray-300' : 'text-gray-700'
)}
>
Subject {requireSubject ? '*' : ''}
</Typography>
</label>
<div className="relative">
<MessageSquare className={cn(
"absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5",
theme === 'dark' ? 'text-gray-400' : 'text-gray-500',
errors.subject && (theme === 'dark' ? 'text-red-400' : 'text-red-500')
)} />
<input
id="contact-subject"
type="text"
value={formData.subject}
onChange={(e) => handleChange('subject', e.target.value)}
onBlur={() => handleBlur('subject')}
placeholder={subjectPlaceholder}
disabled={isSubmitDisabled}
aria-label="Subject"
aria-invalid={!!errors.subject}
aria-describedby={errors.subject ? "contact-subject-error" : undefined}
className={cn(
"w-full pl-10 pr-4 py-3 rounded-lg border-2 transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
theme === 'dark'
? cn(
"bg-gray-900 border-gray-700 text-white placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.subject && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.subject && touched.subject && "border-green-500"
)
: cn(
"bg-white border-gray-300 text-gray-900 placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.subject && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.subject && touched.subject && "border-green-500"
),
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
/>
</div>
{errors.subject && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1 text-sm text-red-500"
id="contact-subject-error"
role="alert"
>
{errors.subject}
</motion.p>
)}
</div>
{/* Message Field - Full width */}
<div className="col-span-1 md:col-span-2">
<div className="flex justify-between items-center mb-2">
<label htmlFor="contact-message">
<Typography
variant="small"
weight="medium"
className={cn(
theme === 'dark' ? 'text-gray-300' : 'text-gray-700'
)}
>
Message *
</Typography>
</label>
{maxMessageLength && (
<Typography
variant="small"
className={cn(
characterCount > maxMessageLength ? 'text-red-500' :
theme === 'dark' ? 'text-gray-400' : 'text-gray-500'
)}
>
{characterCount} / {maxMessageLength}
</Typography>
)}
</div>
<div className="relative">
<textarea
id="contact-message"
value={formData.message}
onChange={(e) => handleChange('message', e.target.value)}
onBlur={() => handleBlur('message')}
placeholder={messagePlaceholder}
disabled={isSubmitDisabled}
rows={5}
aria-label="Message"
aria-invalid={!!errors.message}
aria-describedby={errors.message ? "contact-message-error" : undefined}
className={cn(
"w-full px-4 py-3 rounded-lg border-2 transition-all duration-200 resize-none",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
theme === 'dark'
? cn(
"bg-gray-900 border-gray-700 text-white placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.message && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.message && touched.message && "border-green-500"
)
: cn(
"bg-white border-gray-300 text-gray-900 placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
errors.message && "border-red-500 focus:border-red-500 focus:ring-red-500/50",
!errors.message && touched.message && "border-green-500"
),
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
/>
</div>
{errors.message && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-1 text-sm text-red-500"
id="contact-message-error"
role="alert"
>
{errors.message}
</motion.p>
)}
</div>
</div>
<Button
type="submit"
variant="primary"
size="lg"
disabled={isSubmitDisabled}
className="w-full md:w-auto min-w-[160px]"
>
{status === 'loading' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
{submitText}
</>
)}
</Button>
{status === 'error' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn(
"p-4 rounded-lg border",
theme === 'dark'
? "bg-red-900/20 border-red-800 text-red-400"
: "bg-red-50 border-red-200 text-red-800"
)}
role="alert"
>
<div className="flex items-center gap-2">
<span className="font-medium">Failed to send message</span>
</div>
<p className="mt-1 text-sm">Please try again or contact support if the issue persists.</p>
</motion.div>
)}
</form>
)}
</motion.div>
);
};
/* ============================================
NEWSLETTER COMPONENT (Accepts Children)
============================================ */
/**
* Newsletter signup form component for email subscriptions.
* Features email validation, loading states, success confirmation, and privacy note.
* Accepts child components for custom headings and content.
*
* @component
* @example
* ```tsx
* <CTABannerNewsletter
* onSubmit={handleSubscribe}
* placeholder="Enter your email"
* submitText="Subscribe"
* privacyNote="We respect your privacy. Unsubscribe at any time."
* buttonVariant="primary"
* >
* <NewsletterHeading>Stay Updated</NewsletterHeading>
* <NewsletterSubheading>Get the latest news and offers</NewsletterSubheading>
* </CTABannerNewsletter>
* ```
*
* @param {Object} props - Component props
* @param {string} [props.placeholder="Enter your email address"] - Input placeholder text
* @param {string} [props.submitText="Subscribe"] - Submit button text
* @param {string} [props.privacyNote] - Privacy policy disclaimer text
* @param {('primary'|'secondary'|'outline')} [props.buttonVariant='primary'] - Button style variant
* @param {(email: string) => Promise<void> | void} [props.onSubmit] - Submit handler
* @param {React.ReactNode} [props.children] - Optional child components for headings
* @param {string} [props.className] - Additional CSS classes
* @returns {JSX.Element} Newsletter signup form with validation
*/
export const CTABannerNewsletter: React.FC<{
// Form configuration
placeholder?: string;
submitText?: string;
privacyNote?: string;
buttonVariant?: 'primary' | 'secondary' | 'outline';
layout?: 'inline' | 'stacked';
onSubmit?: (email: string) => Promise<void> | void;
// Children
children?: React.ReactNode;
className?: string;
}> = ({
placeholder = "Enter your email address",
submitText = "Subscribe",
privacyNote = "By subscribing, you agree to our Privacy Policy and consent to receive updates.",
buttonVariant = 'primary',
// layout = 'inline',
onSubmit,
children,
className
}) => {
const { theme, isVisible, animationDelay, animationType } = useCTA();
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [error, setError] = useState<string>('');
// Email validation regex
const validateEmail = (email: string) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate email
if (!validateEmail(email)) {
setError('Please enter a valid email address');
setStatus('error');
return;
}
setStatus('loading');
setError('');
try {
if (onSubmit) {
await onSubmit(email);
}
// Mock API call for demo
setTimeout(() => {
setStatus('success');
setEmail('');
}, 1000);
} catch (err) {
setStatus('error');
setError('Failed to subscribe. Please try again.');
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
// Clear error when user starts typing
if (error && validateEmail(value)) {
setError('');
setStatus('idle');
}
};
const handleBlur = () => {
if (email && !validateEmail(email)) {
setError('Please enter a valid email address');
}
};
return (
<motion.div
initial={animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
animate={isVisible ? {
opacity: 1,
y: 0,
scale: 1
} : animationType === 'fade' ? { opacity: 0 } :
animationType === 'slide' ? { opacity: 0, y: 20 } :
{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.6, delay: animationDelay + 0.4 }}
className={cn("w-full", className)}
>
{/* Children content - can include headings, subheadings, etc. */}
{children && <div className="space-y-4 mb-6">{children}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex flex-col sm:flex-row gap-3 md:gap-4">
<div className="flex-1">
<div className="relative">
<Mail className={cn(
"absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5",
theme === 'dark' ? 'text-gray-400' : 'text-gray-500'
)} />
<input
type="email"
value={email}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
disabled={status === 'loading' || status === 'success'}
aria-label="Email address"
aria-invalid={!!error}
aria-describedby={error ? "email-error" : undefined}
className={cn(
"w-full pl-10 pr-4 py-3 md:py-4 rounded-lg border-2 transition-all duration-200",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
theme === 'dark'
? cn(
"bg-gray-900 border-gray-700 text-white placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
error && "border-red-500 focus:border-red-500 focus:ring-red-500/50"
)
: cn(
"bg-white border-gray-300 text-gray-900 placeholder-gray-500",
"focus:border-blue-500 focus:ring-blue-500/50",
error && "border-red-500 focus:border-red-500 focus:ring-red-500/50"
),
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
/>
</div>
{error && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-sm text-red-500 flex items-center gap-1"
id="email-error"
role="alert"
>
{error}
</motion.p>
)}
</div>
<Button
type="submit"
variant={buttonVariant}
size="lg"
disabled={status === 'loading' || status === 'success'}
className="sm:w-auto min-w-[120px]"
>
{status === 'loading' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Subscribing...
</>
) : status === 'success' ? (
'Subscribed!'
) : (
submitText
)}
</Button>
</div>
{privacyNote && (
<p className={cn(
"text-sm",
theme === 'dark' ? 'text-gray-400' : 'text-gray-600'
)}>
{privacyNote}
</p>
)}
{status === 'success' && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className={cn(
"p-4 rounded-lg border",
theme === 'dark'
? "bg-green-900/20 border-green-800 text-green-400"
: "bg-green-50 border-green-200 text-green-800"
)}
role="alert"
>
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5" />
<p className="font-medium">Successfully subscribed!</p>
</div>
<p className="mt-1 text-sm">Thank you for joining our newsletter.</p>
</motion.div>
)}
</form>
</motion.div>
);
};
/* ============================================
MAIN COMPONENT
============================================ */
/**
* CTABanner - A flexible and composable call-to-action banner component.
*
* This component serves as the main wrapper for all CTA-related content. It provides
* extensive customization options including layouts, themes, animations, and background
* styles. The component uses a compound component pattern where child components like
* CTABannerHeading, CTABannerSubheading, CTABannerActions, etc., must be used inside it.
*
* Features:
* - Multiple layout options: centered, split, compact
* - Theme support: light/dark modes with forced theme capability
* - Various background types: solid, gradient, image
* - Smooth animations with configurable types and delays
* - Accessibility-focused with ARIA labels and proper semantic HTML
* - Split layout support with image positioning
* - Compound component pattern for flexible composition
*
* @component
* @example
* ```tsx
* // Basic centered layout
* <CTABanner
* variant="primary"
* layout="centered"
* padding="lg"
* animate={true}
* >
* <CTABannerHeading>
* Ready to Transform Your Business?
* </CTABannerHeading>
* <CTABannerSubheading>
* Join thousands of satisfied customers who have already made the switch.
* </CTABannerSubheading>
* <CTABannerActions>
* <CTABannerButton
* label="Get Started"
* variant="primary"
* icon={ArrowRight}
* onClick={() => console.log('Get Started clicked')}
* />
* <CTABannerButton
* label="Learn More"
* variant="outline"
* href="/about"
* />
* </CTABannerActions>
* </CTABanner>
*
* // Split layout with image
* <CTABanner
* layout="split"
* imagePosition="right"
* theme="light"
* animationType="slide"
* >
* <CTABannerContent>
* <CTABannerHeading>Schedule a Demo</CTABannerHeading>
* <CTABannerSubheading>
* See our platform in action with a personalized demo.
* </CTABannerSubheading>
* <CTABannerDemoRequest
* onSubmit={handleDemoRequest}
* requireCompany={true}
* />
* </CTABannerContent>
* <CTABannerImage
* src="/images/demo-illustration.jpg"
* alt="Product demo illustration"
* />
* </CTABanner>
*
* // Compact newsletter layout
* <CTABanner
* variant="muted"
* layout="compact"
* contentAlign="center"
* theme="light"
* >
* <CTABannerHeading>
* Stay in the Loop
* </CTABannerHeading>
* <CTABannerSubheading>
* Subscribe to our newsletter for updates and exclusive offers.
* </CTABannerSubheading>
* <CTABannerActions>
* <CTABannerNewsletter
* onSubmit={handleSubscribe}
* placeholder="Enter your email"
* submitText="Subscribe"
* />
* </CTABannerActions>
* </CTABanner>
* ```
*
* @param {Object} props - Component props
* @param {('default'|'primary'|'secondary'|'accent'|'muted'|'gradient'|'glass'|'dark'|'light')} [props.variant='default'] - Visual style variant
* @param {('centered'|'split'|'compact')} [props.layout='centered'] - Layout structure
* @param {('left'|'center'|'right')} [props.contentAlign='center'] - Content alignment
* @param {('solid'|'gradient'|'image')} [props.backgroundType='solid'] - Background type
* @param {string} [props.backgroundColor] - Custom background color (hex/rgb/hsl)
* @param {string} [props.gradientFrom] - Start color for gradient backgrounds
* @param {string} [props.gradientTo] - End color for gradient backgrounds
* @param {string} [props.backgroundImage] - URL for image backgrounds
* @param {('left'|'right')} [props.imagePosition='right'] - Image position in split layout
* @param {('light'|'dark'|'default')} [props.imageVariant='default'] - Image style variant
* @param {('light'|'dark')} [props.theme] - Color theme (auto-detected if not provided)
* @param {boolean} [props.forceTheme=false] - Force theme application regardless of parent
* @param {boolean} [props.animate=true] - Enable/disable animations
* @param {number} [props.animationDelay=0] - Delay before animation starts (seconds)
* @param {('fade'|'slide'|'scale')} [props.animationType='fade'] - Animation type
* @param {('sm'|'md'|'lg'|'xl'|'2xl')} [props.padding='lg'] - Padding size
* @param {React.ReactNode} props.children - Child components (must be CTA components)
* @param {string} [props.ariaLabel='Call to action banner'] - ARIA label for accessibility
* @param {string} [props.role='banner'] - ARIA role
* @returns {JSX.Element} Rendered CTA banner with all child components
*
* @see {@link CTABannerHeading} - For the main heading component
* @see {@link CTABannerSubheading} - For supporting text
* @see {@link CTABannerActions} - For button container
* @see {@link CTABannerButton} - For individual buttons
* @see {@link CTABannerImage} - For images in split layout
* @see {@link CTABannerContent} - For content wrapper in split layout
* @see {@link CTABannerDemoRequest} - For demo request form
* @see {@link CTABannerContactForm} - For contact form
* @see {@link CTABannerNewsletter} - For newsletter signup
*/
export const CTABanner: React.FC<CTABannerProps> = ({
// Layout & Variants
variant = "default",
layout = "centered",
contentAlign = "center",
// Background & Styling
backgroundType = "solid",
backgroundColor,
gradientFrom,
gradientTo,
backgroundImage,
// Image Options
imagePosition = "right",
// imageVariant = "default",
// Theme
theme,
forceTheme = false,
// Animation
animate = true,
animationDelay = 0,
animationType = "fade",
// Spacing
padding = "lg",
// Children
children,
// Accessibility
ariaLabel = "Call to action banner",
role = "banner",
}) => {
// State for animation
const [isVisible, setIsVisible] = useState(false);
// Determine theme based on props or variant
const resolvedTheme = theme ||
(variant === 'dark' ? 'dark' :
variant === 'light' ? 'light' :
variant === 'gradient' ? 'dark' : 'light');
// Handle background styling
const backgroundStyle: React.CSSProperties = {};
if (backgroundType === 'gradient' && gradientFrom && gradientTo) {
backgroundStyle.background = `linear-gradient(135deg, ${gradientFrom}, ${gradientTo})`;
} else if (backgroundType === 'solid' && backgroundColor) {
backgroundStyle.backgroundColor = backgroundColor;
} else if (backgroundType === 'image' && backgroundImage) {
backgroundStyle.backgroundImage = `url(${backgroundImage})`;
backgroundStyle.backgroundSize = 'cover';
backgroundStyle.backgroundPosition = 'center';
}
// Handle button click
const handleButtonClick = (button: CTAButton) => {
if (button.onClick) {
button.onClick();
} else if (button.href) {
if (button.external) {
window.open(button.href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = button.href;
}
}
};
// Trigger animation on mount
useEffect(() => {
if (animate) {
const timer = setTimeout(() => {
setIsVisible(true);
}, 100);
return () => clearTimeout(timer);
}
}, [animate]);
// Context value
const contextValue: CTAContextType = {
theme: resolvedTheme,
layout,
contentAlign,
variant: typeof variant === 'string' ? variant : 'default',
imagePosition,
isVisible,
animationDelay,
animationType,
handleButtonClick,
};
// For split layout, handle image and content arrangement
if (layout === 'split') {
const childrenArray = React.Children.toArray(children);
// Find content and image components
const content = childrenArray.find(
(child): child is React.ReactElement =>
React.isValidElement(child) &&
child.type === CTABannerContent
);
const image = childrenArray.find(
(child): child is React.ReactElement =>
React.isValidElement(child) &&
child.type === CTABannerImage
);
return (
<CTAContext.Provider value={contextValue}>
<section
className={cn(
bannerVariants({ variant, padding, layout }),
forceTheme && resolvedTheme === 'dark' && 'dark',
"relative"
)}
style={backgroundStyle}
aria-label={ariaLabel}
role={role}
>
<div className={containerVariants({ layout })}>
<div className="flex flex-col lg:flex-row items-center gap-8 lg:gap-12">
{imagePosition === 'left' && image}
{content}
{imagePosition === 'right' && image}
</div>
</div>
</section>
</CTAContext.Provider>
);
}
// Render centered/compact layout
return (
<CTAContext.Provider value={contextValue}>
<section
className={cn(
bannerVariants({ variant, padding, layout }),
forceTheme && resolvedTheme === 'dark' && 'dark',
"relative"
)}
style={backgroundStyle}
aria-label={ariaLabel}
role={role}
>
{/* Background effects for gradient variant */}
{variant === 'gradient' && (
<>
<div className="absolute top-0 left-0 w-64 h-64 bg-gradient-to-r from-primary/30 to-transparent rounded-full blur-3xl" />
<div className="absolute bottom-0 right-0 w-64 h-64 bg-gradient-to-l from-accent/30 to-transparent rounded-full blur-3xl" />
</>
)}
<div className={containerVariants({ layout })}>
<CTABannerContent>
{/* Decorative icon for certain variants */}
{(variant === 'primary' || variant === 'gradient') && layout === 'centered' && (
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={isVisible ? { scale: 1, rotate: 0 } : { scale: 0, rotate: -180 }}
transition={{ duration: 0.8, delay: animationDelay + 0.1, type: "spring" }}
className="w-16 h-16 mx-auto mb-6 rounded-full bg-white/10 flex items-center justify-center"
>
<Sparkles className="w-8 h-8 text-white" />
</motion.div>
)}
{children}
{/* Additional info for compact layout */}
{layout === 'compact' && (
<motion.div
initial={{ opacity: 0 }}
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.5, delay: animationDelay + 0.5 }}
className="mt-6 flex items-center justify-center gap-4 text-sm"
>
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className={resolvedTheme === 'dark' ? 'text-gray-400' : 'text-gray-600'}>
No commitment required
</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className={resolvedTheme === 'dark' ? 'text-gray-400' : 'text-gray-600'}>
Free consultation
</span>
</div>
</motion.div>
)}
</CTABannerContent>
</div>
</section>
</CTAContext.Provider>
);
};
// Export types
export type { CTABannerProps };
Props
CTABanner Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'primary' | 'secondary' | 'accent' | 'muted' | 'gradient' | 'glass' | 'dark' | 'light' | 'default' | Visual variant of the banner |
layout | 'centered' | 'split' | 'compact' 'centered' Layout arrangement | ||
contentAlign | 'left' | 'center' | 'right' | 'center' | Content alignment within the banner |
backgroundType | 'solid' | 'gradient' | 'image' | 'solid' | Type of background styling |
backgroundColor | string | - | Custom background color (for solid background) |
gradientFrom | string | - | Start color for gradient background |
gradientTo | string | - | End color for gradient background |
backgroundImage | string | - | URL for background image |
imagePosition | 'left' | 'right' | 'right' | Image position for split layout |
imageVariant | 'light' | 'dark' | 'default' | 'default' | Image styling variant (affects brightness/contrast) |
theme | 'light' | 'dark' | - | Force a specific theme (overrides variant-based theme) |
forceTheme | boolean | false | Force theme CSS classes |
animate | boolean | true | Enable/disable animations |
animationDelay | number | 0 | Initial animation delay in seconds |
animationType | 'fade' | 'slide' | 'scale' | 'fade' | Type of entrance animation |
padding | 'sm' | 'md' | 'lg' | 'xl' | '2xl ' | 'lg ' | Vertical padding size |
ariaLabel | string | 'Call to action banner ' | Accessibility label |
role | string | 'banner' | ARIA role |
children | React.ReactNode | required | Banner content components |
CTABannerHeading Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | required | Heading text content |
className | string | - | Additional CSS classes |
CTABannerSubheading Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | required | Subheading text content |
className | string | - | Additional CSS classes |
CTABannerActions Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | required | Action buttons |
className | string | - | Additional CSS classes |
CTABannerButton Props
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | required | Button text |
variant | 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'primary' | Button variant |
icon | React.ElementType | - | Optional icon component |
onClick | () => void | - | Click handler |
href | string | - | Link URL (renders as anchor tag) |
external | boolean | false | Open link in new tab |
size | 'sm' | 'md' | 'lg' | `'md' (compact layout) / 'lg' (centered/split) | ` Button size (auto-detected from layout if not provided) |
className | string | - | Additional CSS classes |
CTABannerContent Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | required | Content elements (heading, subheading, actions) |
className | string | - | Additional CSS classes |
CTABannerImage Props
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | required | Image source URL |
alt | string | 'CTA Visual' | Image alt text |
className | string | - | Additional CSS classes |
variant | 'light' | 'dark' | 'default' | - | Image styling variant (overrides banner's imageVariant) |