Velocity X Components by Aidxn Design

Custom Framer Component · Free, unlicensed

Testimonials Mobile Carousel

A swipeable, mobile-first testimonials carousel with glass-morphism styling options, auto-rotate, and an avatar/dots selector.

testimonialscarouselmobilereviews

This is a standalone Framer Code File — not installed with the CLI, not gated behind a subscription. Copy the whole file and paste it into Framer via Assets → Code → Create Code File.

Property controls

These are Framer's own addPropertyControls fields, which show up as the right-hand properties panel once this is pasted into Framer. They are not React props and have nothing to do with this site's licensed component API.

Panel label Control key Control type
Testimonials testimonials Array
Quote Font quoteFont Font
Quote Color quoteColor Color
Background backgroundColor Color
Background Blur backgroundBlur Number
Active Pill Color activeColor Color
Hover Color hoverColor Color
Role Color roleColor Color
Show Quotes showQuotationMarks Boolean
Mark Color quoteMarkColor Color
Mark Size quoteMarkSize Number
Mark Opacity quotationMarkOpacity Number
Animation Speed animationDuration Number
Gap gap Number
Avatar Size avatarSize Number
Border Radius borderRadius Number
Mobile Breakpoint // Mobile-specific controls mobileBreakpoint Number
Mobile Font Size mobileFontSize String
Mobile Padding mobilePadding Number
Mobile Avatar Size mobileAvatarSize Number
Mobile Quote Size mobileQuoteMarkSize Number
Swipe Gestures enableSwipeGestures Boolean
Auto Rotate (Mobile) autoRotate Boolean
Rotate Interval autoRotateInterval Number
Mobile Max Width mobileMaxWidth String
Mobile Gap mobileGap Number
Show Dots (Mobile) showDotsOnMobile Boolean
Dot Size mobileButtonSize Number
Glass Effect // Glass morphism controls (simplified grouping) enableGlassMorphism Boolean
Glass Opacity glassOpacity Number
Glass Blur glassBlur Number
Iridescence enableIridescence Boolean
Iridescent Intensity iridescentIntensity Number
Iridescent Speed iridescentSpeed Number
Noise Texture enableNoise Boolean
Noise Opacity noiseOpacity Number
Specular Highlight enableSpecularHighlight Boolean
Highlight Opacity specularOpacity Number
Edge Glow enableEdgeGlow Boolean
Glow Color edgeGlowColor Color
Glow Intensity edgeGlowIntensity Number
Refraction Distortion enableRefraction Boolean
Refraction Intensity refractionIntensity Number
Refraction Blur refractionBlur Number

Component source

The complete, unmodified file — copy the whole thing.

tsx
import { useState, useEffect } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { addPropertyControls, ControlType, RenderTarget } from "framer"

export const META = {
    slug: "testimonials-mobile-carousel",
    title: "Testimonials Mobile Carousel",
    description: "A swipeable, mobile-first testimonials carousel with glass-morphism styling options, auto-rotate, and an avatar/dots selector.",
    tags: ["testimonials", "carousel", "mobile", "reviews"],
    category: "custom-framer",
}

interface TestimonialItem {
    name: string
    location: string
    rating: number
    service: string
    date: string
    review: string
    avatar?: string
}

interface Props {
    testimonials: TestimonialItem[]
    quoteFont: {
        fontSize: string
        fontWeight: number
        fontFamily?: string
        fontStyle?: string
        letterSpacing?: string
        lineHeight?: string | number
    }
    quoteColor: string
    backgroundColor: string
    activeColor: string
    hoverColor: string
    roleColor: string
    // Quotation Mark Props
    showQuotationMarks: boolean
    quoteMarkColor: string
    quoteMarkSize: number
    quotationMarkOpacity: number

    animationDuration: number
    gap: number
    avatarSize: number
    backgroundBlur: number
    borderRadius: number

    // Glass morphism props
    enableGlassMorphism: boolean
    glassOpacity: number
    glassBlur: number
    enableIridescence: boolean
    iridescentIntensity: number
    iridescentSpeed: number
    enableNoise: boolean
    noiseOpacity: number
    enableSpecularHighlight: boolean
    specularOpacity: number
    enableEdgeGlow: boolean
    edgeGlowColor: string
    edgeGlowIntensity: number
    enableRefraction: boolean
    refractionIntensity: number
    refractionBlur: number

    // Mobile-specific props
    mobileBreakpoint: number
    mobileFontSize: string
    mobilePadding: number
    mobileAvatarSize: number
    mobileQuoteMarkSize: number
    enableSwipeGestures: boolean
    autoRotate: boolean
    autoRotateInterval: number
    mobileMaxWidth: string
    mobileGap: number
    showDotsOnMobile: boolean
    mobileButtonSize: number
}

export default function TestimonialsMobileCarousel(props: Props) {
    const {
        testimonials,
        quoteFont,
        quoteColor,
        backgroundColor,
        activeColor,
        hoverColor,
        roleColor,
        showQuotationMarks,
        quoteMarkColor,
        quoteMarkSize,
        quotationMarkOpacity,
        animationDuration,
        gap,
        avatarSize,
        backgroundBlur,
        borderRadius,
        enableGlassMorphism,
        glassOpacity,
        glassBlur,
        enableIridescence,
        iridescentIntensity,
        iridescentSpeed,
        enableNoise,
        noiseOpacity,
        enableSpecularHighlight,
        specularOpacity,
        enableEdgeGlow,
        edgeGlowColor,
        edgeGlowIntensity,
        enableRefraction,
        refractionIntensity,
        refractionBlur,
        mobileBreakpoint,
        mobileFontSize,
        mobilePadding,
        mobileAvatarSize,
        mobileQuoteMarkSize,
        enableSwipeGestures,
        autoRotate,
        autoRotateInterval,
        mobileMaxWidth,
        mobileGap,
        showDotsOnMobile,
        mobileButtonSize,
    } = props

    const [activeIndex, setActiveIndex] = useState(0)
    const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
    const [isMobile, setIsMobile] = useState(false)
    const [touchStart, setTouchStart] = useState<number | null>(null)
    const [touchEnd, setTouchEnd] = useState<number | null>(null)

    const isCanvas = RenderTarget.current() === RenderTarget.canvas

    // Check for mobile viewport
    useEffect(() => {
        const checkMobile = () => {
            setIsMobile(window.innerWidth <= mobileBreakpoint)
        }

        checkMobile()
        window.addEventListener("resize", checkMobile)

        return () => window.removeEventListener("resize", checkMobile)
    }, [mobileBreakpoint])

    // Auto-rotate functionality
    useEffect(() => {
        if (!autoRotate || !isMobile) return

        const interval = setInterval(() => {
            setActiveIndex((prev) => (prev + 1) % testimonials.length)
        }, autoRotateInterval * 1000)

        return () => clearInterval(interval)
    }, [autoRotate, autoRotateInterval, isMobile, testimonials.length])

    // Touch handling for swipe gestures
    const handleTouchStart = (e: React.TouchEvent) => {
        if (!enableSwipeGestures || !isMobile) return
        setTouchEnd(null)
        setTouchStart(e.targetTouches[0].clientX)
    }

    const handleTouchMove = (e: React.TouchEvent) => {
        if (!enableSwipeGestures || !isMobile) return
        setTouchEnd(e.targetTouches[0].clientX)
    }

    const handleTouchEnd = () => {
        if (!touchStart || !touchEnd || !enableSwipeGestures || !isMobile) return

        const distance = touchStart - touchEnd
        const isLeftSwipe = distance > 50
        const isRightSwipe = distance < -50

        if (isLeftSwipe && activeIndex < testimonials.length - 1) {
            setActiveIndex(activeIndex + 1)
        }
        if (isRightSwipe && activeIndex > 0) {
            setActiveIndex(activeIndex - 1)
        }
    }

    const handleSelect = (index: number) => {
        if (index !== activeIndex) {
            setActiveIndex(index)
        }
    }

    const activeTestimonial = testimonials[activeIndex]

    // Responsive values
    const currentPadding = isMobile ? mobilePadding : 32
    const currentAvatarSize = isMobile ? mobileAvatarSize : avatarSize
    const currentQuoteMarkSize = isMobile ? mobileQuoteMarkSize : quoteMarkSize
    const currentGap = isMobile ? mobileGap : gap
    const currentFontSize = isMobile ? mobileFontSize : quoteFont.fontSize
    const currentMaxWidth = isMobile ? mobileMaxWidth : "512px"

    return (
        <div
            style={{
                position: "relative",
                width: "100%",
                height: "100%",
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                backgroundColor: enableGlassMorphism
                    ? `rgba(255, 255, 255, ${glassOpacity})`
                    : backgroundColor,
                backdropFilter: enableGlassMorphism
                    ? `blur(${glassBlur}px)`
                    : `blur(${backgroundBlur}px)`,
                WebkitBackdropFilter: enableGlassMorphism
                    ? `blur(${glassBlur}px)`
                    : `blur(${backgroundBlur}px)`,
                borderRadius: `${borderRadius}px`,
                padding: `${currentPadding}px`,
                overflow: "hidden",
                boxShadow: enableEdgeGlow
                    ? `inset 0 0 ${edgeGlowIntensity}px ${edgeGlowColor}`
                    : "none",
                touchAction: enableSwipeGestures && isMobile ? "pan-y" : "auto",
            }}
            onTouchStart={handleTouchStart}
            onTouchMove={handleTouchMove}
            onTouchEnd={handleTouchEnd}
        >
            {/* Glass morphism effects - simplified for mobile performance */}
            {enableGlassMorphism && (!isMobile || window.devicePixelRatio <= 2) && (
                <>
                    {/* Noise Texture Overlay */}
                    {enableNoise && (
                        <div
                            style={{
                                position: "absolute",
                                top: 0,
                                left: 0,
                                right: 0,
                                bottom: 0,
                                opacity: noiseOpacity,
                                borderRadius: `${borderRadius}px`,
                                background: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' viewBox='0 0 100 100'%3e%3cdefs%3e%3cfilter id='noiseFilter'%3e%3cfeTurbulence baseFrequency='0.9' numOctaves='4' result='noise'/%3e%3cfeColorMatrix in='noise' type='saturate' values='0'/%3e%3c/filter%3e%3c/defs%3e%3crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3e%3c/svg%3e")`,
                                mixBlendMode: "overlay",
                                pointerEvents: "none",
                            }}
                        />
                    )}

                    {/* Simplified iridescent effects for mobile */}
                    {enableIridescence && !isMobile && (
                        <motion.div
                            animate={{
                                background: [
                                    `conic-gradient(from 0deg, transparent, rgba(255, 0, 150, ${iridescentIntensity}), transparent, rgba(0, 255, 255, ${iridescentIntensity}), transparent)`,
                                    `conic-gradient(from 120deg, transparent, rgba(255, 0, 150, ${iridescentIntensity}), transparent, rgba(0, 255, 255, ${iridescentIntensity}), transparent)`,
                                    `conic-gradient(from 240deg, transparent, rgba(255, 0, 150, ${iridescentIntensity}), transparent, rgba(0, 255, 255, ${iridescentIntensity}), transparent)`,
                                    `conic-gradient(from 360deg, transparent, rgba(255, 0, 150, ${iridescentIntensity}), transparent, rgba(0, 255, 255, ${iridescentIntensity}), transparent)`,
                                ],
                            }}
                            transition={{
                                duration: iridescentSpeed,
                                repeat: Infinity,
                                ease: "linear",
                            }}
                            style={{
                                position: "absolute",
                                top: 0,
                                left: 0,
                                right: 0,
                                bottom: 0,
                                borderRadius: `${borderRadius}px`,
                                mixBlendMode: "overlay",
                                pointerEvents: "none",
                            }}
                        />
                    )}

                    {/* Disable complex effects on mobile for performance */}
                    {!isMobile && enableSpecularHighlight && (
                        <motion.div
                            animate={{
                                x: ["-20%", "120%"],
                                y: ["-20%", "120%"],
                            }}
                            transition={{
                                duration: iridescentSpeed * 2,
                                repeat: Infinity,
                                ease: "linear",
                            }}
                            style={{
                                position: "absolute",
                                width: "30%",
                                height: "30%",
                                background: `radial-gradient(ellipse, rgba(255, 255, 255, ${specularOpacity}) 0%, transparent 70%)`,
                                borderRadius: "50%",
                                filter: "blur(2px)",
                                pointerEvents: "none",
                            }}
                        />
                    )}
                </>
            )}

            {/* Content Container */}
            <div
                style={{
                    position: "relative",
                    zIndex: 1,
                    display: "flex",
                    flexDirection: "column",
                    alignItems: "center",
                    gap: `${currentGap}px`,
                    paddingTop: isMobile ? "32px" : "64px",
                    paddingBottom: isMobile ? "32px" : "64px",
                    width: "100%",
                    maxWidth: "100%",
                }}
            >
                {/* Quote Container */}
                <div
                    style={{
                        position: "relative",
                        paddingLeft: isMobile ? "16px" : "32px",
                        paddingRight: isMobile ? "16px" : "32px",
                        width: "100%",
                        boxSizing: "border-box",
                    }}
                >
                    {showQuotationMarks && (
                        <span
                            style={{
                                position: "absolute",
                                left: isMobile ? "0px" : `-${currentQuoteMarkSize * 0.15}px`,
                                top: `-${currentQuoteMarkSize * 0.35}px`,
                                fontSize: `${currentQuoteMarkSize}px`,
                                fontFamily: "Georgia, serif",
                                color: quoteMarkColor,
                                opacity: quotationMarkOpacity,
                                userSelect: "none",
                                pointerEvents: "none",
                                lineHeight: 1,
                            }}
                        >
                            "
                        </span>
                    )}

                    <AnimatePresence mode="wait">
                        <motion.p
                            key={activeIndex}
                            initial={
                                isCanvas
                                    ? false
                                    : {
                                          opacity: 0,
                                          filter: isMobile ? "none" : "blur(4px)",
                                          scale: 0.98,
                                      }
                            }
                            animate={{
                                opacity: 1,
                                filter: "blur(0px)",
                                scale: 1,
                            }}
                            exit={{
                                opacity: 0,
                                filter: isMobile ? "none" : "blur(4px)",
                                scale: 0.98,
                            }}
                            transition={{
                                duration: animationDuration,
                                ease: "easeOut",
                            }}
                            style={{
                                fontSize: currentFontSize,
                                fontWeight: quoteFont.fontWeight,
                                fontFamily: quoteFont.fontFamily,
                                fontStyle: quoteFont.fontStyle,
                                letterSpacing: quoteFont.letterSpacing,
                                lineHeight: quoteFont.lineHeight || 1.6,
                                color: quoteColor,
                                textAlign: "center",
                                maxWidth: currentMaxWidth,
                                margin: "0 auto",
                                wordWrap: "break-word",
                                hyphens: "auto",
                            }}
                        >
                            {activeTestimonial.review}
                        </motion.p>
                    </AnimatePresence>

                    {showQuotationMarks && (
                        <span
                            style={{
                                position: "absolute",
                                right: isMobile ? "0px" : `-${currentQuoteMarkSize * 0.15}px`,
                                bottom: `-${currentQuoteMarkSize * 0.5}px`,
                                fontSize: `${currentQuoteMarkSize}px`,
                                fontFamily: "Georgia, serif",
                                color: quoteMarkColor,
                                opacity: quotationMarkOpacity,
                                userSelect: "none",
                                pointerEvents: "none",
                                lineHeight: 1,
                            }}
                        >
                            "
                        </span>
                    )}
                </div>

                <div
                    style={{
                        display: "flex",
                        flexDirection: "column",
                        alignItems: "center",
                        gap: isMobile ? "16px" : "24px",
                        marginTop: "8px",
                        width: "100%",
                    }}
                >
                    {/* Role text */}
                    <AnimatePresence mode="wait">
                        <motion.p
                            key={activeIndex}
                            initial={isCanvas ? false : { opacity: 0, y: 8 }}
                            animate={{ opacity: 1, y: 0 }}
                            exit={{ opacity: 0, y: 8 }}
                            transition={{
                                duration: animationDuration * 1.25,
                                ease: "easeOut",
                            }}
                            style={{
                                fontSize: isMobile ? "10px" : "11px",
                                color: roleColor,
                                letterSpacing: "0.2em",
                                textTransform: "uppercase",
                                margin: 0,
                                textAlign: "center",
                                padding: isMobile ? "0 16px" : "0",
                            }}
                        >
                            {activeTestimonial.service} —{" "}
                            {activeTestimonial.location}
                        </motion.p>
                    </AnimatePresence>

                    {/* Mobile-optimized avatar selector */}
                    {isMobile && showDotsOnMobile ? (
                        // Dots indicator for mobile
                        <div
                            style={{
                                display: "flex",
                                gap: "8px",
                                alignItems: "center",
                                justifyContent: "center",
                                padding: "8px",
                            }}
                        >
                            {testimonials.map((_, index) => (
                                <motion.button
                                    key={index}
                                    onClick={() => handleSelect(index)}
                                    style={{
                                        width: mobileButtonSize,
                                        height: mobileButtonSize,
                                        borderRadius: "50%",
                                        border: "none",
                                        cursor: "pointer",
                                        padding: 0,
                                    }}
                                    animate={{
                                        backgroundColor: activeIndex === index ? activeColor : "rgba(0, 0, 0, 0.15)",
                                        scale: activeIndex === index ? 1.2 : 1,
                                    }}
                                    transition={{
                                        duration: animationDuration * 0.75,
                                        ease: "easeOut",
                                    }}
                                    whileTap={{ scale: 0.95 }}
                                />
                            ))}
                        </div>
                    ) : (
                        // Avatar row - with responsive scroll on mobile
                        <div
                            style={{
                                display: "flex",
                                alignItems: "center",
                                justifyContent: "center",
                                gap: "8px",
                                overflowX: isMobile ? "auto" : "visible",
                                maxWidth: "100%",
                                padding: isMobile ? "8px 0" : "0",
                                WebkitOverflowScrolling: "touch",
                                scrollbarWidth: "none",
                                msOverflowStyle: "none",
                            }}
                        >
                            {testimonials.map((testimonial, index) => {
                                const isActive = activeIndex === index
                                const isHovered = hoveredIndex === index && !isActive
                                const showName = (isActive || isHovered) && !isMobile
                                const initials = testimonial.name
                                    .split(" ")
                                    .map((part) => part[0])
                                    .filter(Boolean)
                                    .slice(0, 2)
                                    .join("")
                                    .toUpperCase()

                                return (
                                    <motion.button
                                        key={index}
                                        onClick={() => handleSelect(index)}
                                        onMouseEnter={() => !isMobile && setHoveredIndex(index)}
                                        onMouseLeave={() => !isMobile && setHoveredIndex(null)}
                                        style={{
                                            position: "relative",
                                            display: "flex",
                                            alignItems: "center",
                                            gap: 0,
                                            borderRadius: "9999px",
                                            cursor: "pointer",
                                            border: "none",
                                            overflow: "hidden",
                                            flexShrink: 0,
                                            WebkitTapHighlightColor: "transparent",
                                        }}
                                        animate={{
                                            backgroundColor: isActive
                                                ? activeColor
                                                : isHovered
                                                  ? hoverColor
                                                  : "transparent",
                                            paddingRight: showName ? 16 : 2,
                                            paddingLeft: showName ? 8 : 2,
                                            paddingTop: showName ? 8 : 2,
                                            paddingBottom: showName ? 8 : 2,
                                            boxShadow: isActive
                                                ? isMobile
                                                    ? "0 4px 6px -1px rgba(0, 0, 0, 0.1)"
                                                    : "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)"
                                                : "0 0 0 0 rgba(0, 0, 0, 0)",
                                        }}
                                        transition={{
                                            duration: animationDuration * 1.25,
                                            ease: [0.4, 0, 0.2, 1],
                                        }}
                                        whileTap={{ scale: 0.98 }}
                                    >
                                        {/* Avatar — initials by default. Pass a real `avatar` URL
                                            per testimonial to show a photo instead. */}
                                        <div
                                            style={{
                                                position: "relative",
                                                flexShrink: 0,
                                            }}
                                        >
                                            {testimonial.avatar ? (
                                                <motion.img
                                                    src={testimonial.avatar}
                                                    alt={testimonial.name}
                                                    style={{
                                                        width: `${currentAvatarSize}px`,
                                                        height: `${currentAvatarSize}px`,
                                                        borderRadius: "9999px",
                                                        objectFit: "cover",
                                                    }}
                                                    animate={{
                                                        boxShadow: isActive
                                                            ? "0 0 0 2px rgba(255, 255, 255, 0.3)"
                                                            : "0 0 0 0px rgba(255, 255, 255, 0)",
                                                    }}
                                                    transition={{
                                                        duration: animationDuration * 1.25,
                                                        ease: [0.4, 0, 0.2, 1],
                                                    }}
                                                    whileHover={!isActive && !isMobile ? { scale: 1.05 } : {}}
                                                />
                                            ) : (
                                                <motion.div
                                                    style={{
                                                        width: `${currentAvatarSize}px`,
                                                        height: `${currentAvatarSize}px`,
                                                        borderRadius: "9999px",
                                                        display: "flex",
                                                        alignItems: "center",
                                                        justifyContent: "center",
                                                        background: isActive ? "rgba(255,255,255,0.25)" : "rgba(0,0,0,0.08)",
                                                        color: isActive ? "#ffffff" : quoteColor,
                                                        fontSize: Math.max(10, currentAvatarSize * 0.38),
                                                        fontWeight: 600,
                                                    }}
                                                    animate={{
                                                        boxShadow: isActive
                                                            ? "0 0 0 2px rgba(255, 255, 255, 0.3)"
                                                            : "0 0 0 0px rgba(255, 255, 255, 0)",
                                                    }}
                                                    transition={{
                                                        duration: animationDuration * 1.25,
                                                        ease: [0.4, 0, 0.2, 1],
                                                    }}
                                                    whileHover={!isActive && !isMobile ? { scale: 1.05 } : {}}
                                                >
                                                    {initials}
                                                </motion.div>
                                            )}
                                        </div>

                                        {/* Name - only show on desktop when active/hovered */}
                                        {!isMobile && (
                                            <motion.div
                                                style={{
                                                    display: "grid",
                                                    overflow: "hidden",
                                                }}
                                                animate={{
                                                    gridTemplateColumns: showName ? "1fr" : "0fr",
                                                    opacity: showName ? 1 : 0,
                                                    marginLeft: showName ? 8 : 0,
                                                }}
                                                transition={{
                                                    duration: animationDuration * 1.25,
                                                    ease: [0.4, 0, 0.2, 1],
                                                }}
                                            >
                                                <div style={{ overflow: "hidden" }}>
                                                    <motion.span
                                                        style={{
                                                            fontSize: "14px",
                                                            fontWeight: 500,
                                                            whiteSpace: "nowrap",
                                                            display: "block",
                                                        }}
                                                        animate={{
                                                            color: isActive ? "#ffffff" : quoteColor,
                                                        }}
                                                        transition={{
                                                            duration: animationDuration * 0.75,
                                                        }}
                                                    >
                                                        {testimonial.name}
                                                    </motion.span>
                                                </div>
                                            </motion.div>
                                        )}
                                    </motion.button>
                                )
                            })}
                        </div>
                    )}

                    {/* Swipe hint for mobile */}
                    {isMobile && enableSwipeGestures && (
                        <motion.p
                            initial={{ opacity: 0 }}
                            animate={{ opacity: 0.5 }}
                            transition={{ delay: 2, duration: 1 }}
                            style={{
                                fontSize: "11px",
                                color: roleColor,
                                margin: 0,
                                marginTop: "8px",
                            }}
                        >
                            Swipe to navigate
                        </motion.p>
                    )}
                </div>
            </div>
        </div>
    )
}

TestimonialsMobileCarousel.defaultProps = {
    testimonials: [
        {
            name: "Jamie R.",
            location: "Brisbane, QLD",
            rating: 5,
            service: "Full renovation",
            date: "November 2025",
            review: "From the first call to the final walkthrough, everything was handled clearly and on schedule. The team kept us informed at every step and the result exceeded what we expected. From start to finish it was about 6 weeks and the quality is outstanding. Can't recommend them enough!",
        },
        {
            name: "Morgan T.",
            location: "Newcastle, NSW",
            rating: 5,
            service: "Consultation",
            date: "September 2025",
            review: "I'd been putting off dealing with this for months. A mate recommended this team and honestly, they made it painless. Their project team sorted the scope of works end to end, and everyone who came out was professional and respectful of our property. Good as new.",
        },
        {
            name: "Casey O.",
            location: "Toowoomba, QLD",
            rating: 5,
            service: "Project delivery",
            date: "January 2026",
            review: "I was nervous about a big job on my home. The team were genuinely lovely — they explained every step, used quality materials, and even cleaned up afterwards. It has completely transformed the look of our place. Five stars all day.",
        },
    ],
    quoteFont: {
        fontSize: "28px",
        fontWeight: 300,
        lineHeight: 1.6,
    },
    quoteColor: "#000000",
    backgroundColor: "#ffffff",
    activeColor: "#000000",
    hoverColor: "rgba(0, 0, 0, 0.05)",
    roleColor: "#6b7280",
    showQuotationMarks: true,
    quoteMarkColor: "#000000",
    quoteMarkSize: 72,
    quotationMarkOpacity: 0.06,
    animationDuration: 0.4,
    gap: 40,
    avatarSize: 32,
    backgroundBlur: 0,
    borderRadius: 16,
    enableGlassMorphism: false,
    glassOpacity: 0.1,
    glassBlur: 20,
    enableIridescence: true,
    iridescentIntensity: 0.15,
    iridescentSpeed: 8,
    enableNoise: true,
    noiseOpacity: 0.05,
    enableSpecularHighlight: true,
    specularOpacity: 0.3,
    enableEdgeGlow: true,
    edgeGlowColor: "rgba(255, 255, 255, 0.2)",
    edgeGlowIntensity: 20,
    enableRefraction: true,
    refractionIntensity: 3,
    refractionBlur: 8,
    // Mobile defaults
    mobileBreakpoint: 768,
    mobileFontSize: "20px",
    mobilePadding: 20,
    mobileAvatarSize: 28,
    mobileQuoteMarkSize: 48,
    enableSwipeGestures: true,
    autoRotate: false,
    autoRotateInterval: 5,
    mobileMaxWidth: "100%",
    mobileGap: 24,
    showDotsOnMobile: false,
    mobileButtonSize: 8,
}

addPropertyControls(TestimonialsMobileCarousel, {
    testimonials: {
        type: ControlType.Array,
        title: "Testimonials",
        control: {
            type: ControlType.Object,
            controls: {
                name: {
                    type: ControlType.String,
                    title: "Name",
                    defaultValue: "John Doe",
                },
                location: {
                    type: ControlType.String,
                    title: "Location",
                    defaultValue: "Sydney, NSW",
                },
                rating: {
                    type: ControlType.Number,
                    title: "Rating",
                    defaultValue: 5,
                    min: 1,
                    max: 5,
                    step: 1,
                },
                service: {
                    type: ControlType.String,
                    title: "Service",
                    defaultValue: "Full renovation",
                },
                date: {
                    type: ControlType.String,
                    title: "Date",
                    defaultValue: "January 2026",
                },
                review: {
                    type: ControlType.String,
                    title: "Review",
                    defaultValue: "Great service and quality work.",
                },
                avatar: {
                    type: ControlType.Image,
                    title: "Avatar",
                    description: "Optional. Leave empty to show the person's initials instead.",
                },
            },
        },
    },
    quoteFont: {
        type: ControlType.Font,
        title: "Quote Font",
        defaultValue: {
            fontSize: "28px",
            fontWeight: 300,
            lineHeight: 1.4,
        },
        controls: "extended",
    },
    quoteColor: {
        type: ControlType.Color,
        title: "Quote Color",
        defaultValue: "#000000",
    },
    backgroundColor: {
        type: ControlType.Color,
        title: "Background",
        defaultValue: "#ffffff",
    },
    backgroundBlur: {
        type: ControlType.Number,
        title: "Background Blur",
        defaultValue: 0,
        min: 0,
        max: 50,
        step: 1,
        unit: "px",
        displayStepper: true,
    },
    activeColor: {
        type: ControlType.Color,
        title: "Active Pill Color",
        defaultValue: "#000000",
    },
    hoverColor: {
        type: ControlType.Color,
        title: "Hover Color",
        defaultValue: "rgba(0, 0, 0, 0.05)",
    },
    roleColor: {
        type: ControlType.Color,
        title: "Role Color",
        defaultValue: "#6b7280",
    },
    showQuotationMarks: {
        type: ControlType.Boolean,
        title: "Show Quotes",
        defaultValue: true,
        enabledTitle: "Show",
        disabledTitle: "Hide",
    },
    quoteMarkColor: {
        type: ControlType.Color,
        title: "Mark Color",
        defaultValue: "#000000",
        hidden: (props) => props.showQuotationMarks === false,
    },
    quoteMarkSize: {
        type: ControlType.Number,
        title: "Mark Size",
        defaultValue: 72,
        min: 24,
        max: 200,
        step: 4,
        displayStepper: true,
        hidden: (props) => props.showQuotationMarks === false,
    },
    quotationMarkOpacity: {
        type: ControlType.Number,
        title: "Mark Opacity",
        defaultValue: 0.06,
        min: 0,
        max: 1,
        step: 0.01,
        hidden: (props) => props.showQuotationMarks === false,
    },
    animationDuration: {
        type: ControlType.Number,
        title: "Animation Speed",
        defaultValue: 0.4,
        min: 0.1,
        max: 2,
        step: 0.1,
        unit: "s",
        displayStepper: true,
    },
    gap: {
        type: ControlType.Number,
        title: "Gap",
        defaultValue: 40,
        min: 10,
        max: 100,
        step: 5,
        unit: "px",
        displayStepper: true,
    },
    avatarSize: {
        type: ControlType.Number,
        title: "Avatar Size",
        defaultValue: 32,
        min: 24,
        max: 64,
        step: 4,
        unit: "px",
        displayStepper: true,
    },
    borderRadius: {
        type: ControlType.Number,
        title: "Border Radius",
        defaultValue: 16,
        min: 0,
        max: 50,
        step: 2,
        unit: "px",
        displayStepper: true,
    },
    // Mobile-specific controls
    mobileBreakpoint: {
        type: ControlType.Number,
        title: "Mobile Breakpoint",
        defaultValue: 768,
        min: 320,
        max: 1024,
        step: 10,
        unit: "px",
        displayStepper: true,
    },
    mobileFontSize: {
        type: ControlType.String,
        title: "Mobile Font Size",
        defaultValue: "20px",
    },
    mobilePadding: {
        type: ControlType.Number,
        title: "Mobile Padding",
        defaultValue: 20,
        min: 10,
        max: 50,
        step: 2,
        unit: "px",
        displayStepper: true,
    },
    mobileAvatarSize: {
        type: ControlType.Number,
        title: "Mobile Avatar Size",
        defaultValue: 28,
        min: 20,
        max: 48,
        step: 2,
        unit: "px",
        displayStepper: true,
    },
    mobileQuoteMarkSize: {
        type: ControlType.Number,
        title: "Mobile Quote Size",
        defaultValue: 48,
        min: 24,
        max: 100,
        step: 4,
        displayStepper: true,
    },
    enableSwipeGestures: {
        type: ControlType.Boolean,
        title: "Swipe Gestures",
        defaultValue: true,
        enabledTitle: "Enabled",
        disabledTitle: "Disabled",
    },
    autoRotate: {
        type: ControlType.Boolean,
        title: "Auto Rotate (Mobile)",
        defaultValue: false,
        enabledTitle: "On",
        disabledTitle: "Off",
    },
    autoRotateInterval: {
        type: ControlType.Number,
        title: "Rotate Interval",
        defaultValue: 5,
        min: 2,
        max: 10,
        step: 1,
        unit: "s",
        displayStepper: true,
        hidden: (props) => !props.autoRotate,
    },
    mobileMaxWidth: {
        type: ControlType.String,
        title: "Mobile Max Width",
        defaultValue: "100%",
    },
    mobileGap: {
        type: ControlType.Number,
        title: "Mobile Gap",
        defaultValue: 24,
        min: 10,
        max: 60,
        step: 2,
        unit: "px",
        displayStepper: true,
    },
    showDotsOnMobile: {
        type: ControlType.Boolean,
        title: "Show Dots (Mobile)",
        defaultValue: false,
        enabledTitle: "Dots",
        disabledTitle: "Avatars",
    },
    mobileButtonSize: {
        type: ControlType.Number,
        title: "Dot Size",
        defaultValue: 8,
        min: 4,
        max: 16,
        step: 2,
        unit: "px",
        displayStepper: true,
        hidden: (props) => !props.showDotsOnMobile,
    },
    // Glass morphism controls (simplified grouping)
    enableGlassMorphism: {
        type: ControlType.Boolean,
        title: "Glass Effect",
        defaultValue: false,
        enabledTitle: "Enabled",
        disabledTitle: "Disabled",
    },
    glassOpacity: {
        type: ControlType.Number,
        title: "Glass Opacity",
        defaultValue: 0.1,
        min: 0,
        max: 1,
        step: 0.05,
        hidden: (props) => !props.enableGlassMorphism,
    },
    glassBlur: {
        type: ControlType.Number,
        title: "Glass Blur",
        defaultValue: 20,
        min: 0,
        max: 50,
        step: 2,
        unit: "px",
        hidden: (props) => !props.enableGlassMorphism,
        displayStepper: true,
    },
    enableIridescence: {
        type: ControlType.Boolean,
        title: "Iridescence",
        defaultValue: true,
        enabledTitle: "On",
        disabledTitle: "Off",
        hidden: (props) => !props.enableGlassMorphism,
    },
    iridescentIntensity: {
        type: ControlType.Number,
        title: "Iridescent Intensity",
        defaultValue: 0.15,
        min: 0,
        max: 1,
        step: 0.05,
        hidden: (props) => !props.enableGlassMorphism || !props.enableIridescence,
    },
    iridescentSpeed: {
        type: ControlType.Number,
        title: "Iridescent Speed",
        defaultValue: 8,
        min: 2,
        max: 20,
        step: 1,
        unit: "s",
        hidden: (props) => !props.enableGlassMorphism || !props.enableIridescence,
        displayStepper: true,
    },
    enableNoise: {
        type: ControlType.Boolean,
        title: "Noise Texture",
        defaultValue: true,
        enabledTitle: "On",
        disabledTitle: "Off",
        hidden: (props) => !props.enableGlassMorphism,
    },
    noiseOpacity: {
        type: ControlType.Number,
        title: "Noise Opacity",
        defaultValue: 0.05,
        min: 0,
        max: 0.3,
        step: 0.01,
        hidden: (props) => !props.enableGlassMorphism || !props.enableNoise,
    },
    enableSpecularHighlight: {
        type: ControlType.Boolean,
        title: "Specular Highlight",
        defaultValue: true,
        enabledTitle: "On",
        disabledTitle: "Off",
        hidden: (props) => !props.enableGlassMorphism,
    },
    specularOpacity: {
        type: ControlType.Number,
        title: "Highlight Opacity",
        defaultValue: 0.3,
        min: 0,
        max: 1,
        step: 0.05,
        hidden: (props) => !props.enableGlassMorphism || !props.enableSpecularHighlight,
    },
    enableEdgeGlow: {
        type: ControlType.Boolean,
        title: "Edge Glow",
        defaultValue: true,
        enabledTitle: "On",
        disabledTitle: "Off",
        hidden: (props) => !props.enableGlassMorphism,
    },
    edgeGlowColor: {
        type: ControlType.Color,
        title: "Glow Color",
        defaultValue: "rgba(255, 255, 255, 0.2)",
        hidden: (props) => !props.enableGlassMorphism || !props.enableEdgeGlow,
    },
    edgeGlowIntensity: {
        type: ControlType.Number,
        title: "Glow Intensity",
        defaultValue: 20,
        min: 0,
        max: 50,
        step: 2,
        unit: "px",
        hidden: (props) => !props.enableGlassMorphism || !props.enableEdgeGlow,
        displayStepper: true,
    },
    enableRefraction: {
        type: ControlType.Boolean,
        title: "Refraction Distortion",
        defaultValue: false, // Disabled by default on mobile
        enabledTitle: "On",
        disabledTitle: "Off",
        hidden: (props) => !props.enableGlassMorphism,
    },
    refractionIntensity: {
        type: ControlType.Number,
        title: "Refraction Intensity",
        defaultValue: 3,
        min: 0,
        max: 10,
        step: 0.5,
        unit: "px",
        hidden: (props) => !props.enableGlassMorphism || !props.enableRefraction,
        displayStepper: true,
    },
    refractionBlur: {
        type: ControlType.Number,
        title: "Refraction Blur",
        defaultValue: 8,
        min: 0,
        max: 20,
        step: 1,
        unit: "px",
        hidden: (props) => !props.enableGlassMorphism || !props.enableRefraction,
        displayStepper: true,
    },
})
  1. 1. In Framer: Assets → Code → Create Code File.
  2. 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.