Velocity X Components by Aidxn Design

Custom Framer Component · Free, unlicensed

Testimonials Slider

A card-based testimonial carousel with autoplay, swipe/drag support, and keyboard navigation. Fully themeable via colour, size and font property controls.

testimonialscarouselslidersocial proofcards

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
Autoplay autoPlay Boolean
Delay autoPlayDelay Number
Card Width cardWidth Number
Card Height cardHeight Number
Background background Color
Card cardColor Color
Border cardBorder Color
Text textColor Color
Muted Text mutedColor Color
Accent accentColor Color
Font font Font

Component source

The complete, unmodified file — copy the whole thing.

tsx
import * as React from "react"
import { addPropertyControls, ControlType } from "framer"

/**
 * META — read by scripts/gen-framer-components.mjs to build
 * src/data/framer-components.json. Keep this a plain object literal: string
 * and string-array values only, no function calls, no template interpolation.
 * This is the single source of truth for this component's catalogue entry —
 * there is no second, hand-maintained manifest to keep in sync.
 */
export const META = {
    slug: "testimonials-slider",
    title: "Testimonials Slider",
    description:
        "A card-based testimonial carousel with autoplay, swipe/drag support, and keyboard navigation. Fully themeable via colour, size and font property controls.",
    tags: ["testimonials", "carousel", "slider", "social proof", "cards"],
    category: "custom-framer",
}

type Testimonial = {
    name: string
    role: string
    quote: string
}

type Props = {
    testimonials?: Testimonial[]
    autoPlay?: boolean
    autoPlayDelay?: number
    background?: string
    cardColor?: string
    cardBorder?: string
    textColor?: string
    mutedColor?: string
    accentColor?: string
    cardWidth?: number
    cardHeight?: number
    font?: React.CSSProperties
    style?: React.CSSProperties
}

const DEFAULT_TESTIMONIALS: Testimonial[] = [
    {
        name: "Sophie & Daniel",
        role: "Custom Home Clients",
        quote:
            "From the first sketch to handover, every detail felt considered. The team made a complex build feel remarkably straightforward.",
    },
    {
        name: "Michael Turner",
        role: "Home Renovation Client",
        quote:
            "Clear communication, exceptional workmanship, and no surprises. Our finished home is even better than we imagined.",
    },
    {
        name: "Olivia Harris",
        role: "New Build Client",
        quote:
            "They listened closely, solved every challenge quickly, and delivered a home that genuinely feels like ours.",
    },
    {
        name: "James & Emily",
        role: "Knockdown Rebuild Clients",
        quote:
            "The care shown by every trade was outstanding. The result is beautifully resolved, practical, and built to last.",
    },
    {
        name: "Laura Bennett",
        role: "Architectural Build Client",
        quote:
            "A calm, capable team with an eye for the smallest details. We always knew where the project stood and what came next.",
    },
]

const CSS = `
.fc-testimonials {
    --fc-background: #080806;
    --fc-card: #12110f;
    --fc-border: #302d27;
    --fc-text: #f5f3ee;
    --fc-muted: #aaa59b;
    --fc-accent: #ff5500;
    position: relative;
    width: 100%;
    height: 100%;
    min-height: 420px;
    overflow: hidden;
    isolation: isolate;
    container-type: inline-size;
    background: var(--fc-background);
    color: var(--fc-text);
    outline: none;
    -webkit-font-smoothing: antialiased;
    touch-action: pan-y;
}

.fc-testimonials,
.fc-testimonials * {
    box-sizing: border-box;
}

.fc-testimonials:focus-visible {
    box-shadow: inset 0 0 0 2px var(--fc-accent);
}

.fc-testimonials__grid {
    position: absolute;
    z-index: 0;
    inset: 0;
    pointer-events: none;
    background-image: linear-gradient(
        to right,
        transparent calc(25% - 1px),
        color-mix(in srgb, var(--fc-border) 55%, transparent) calc(25% - 1px),
        color-mix(in srgb, var(--fc-border) 55%, transparent) 25%,
        transparent 25%
    );
    background-size: 25% 100%;
}

.fc-testimonials__viewport {
    position: absolute;
    z-index: 1;
    inset: 0 0 84px;
}

.fc-testimonials__card {
    position: absolute;
    left: 50%;
    top: 50%;
    display: flex;
    flex-direction: column;
    width: min(var(--fc-card-width), calc(100% - 48px));
    height: min(var(--fc-card-height), calc(100% - 48px));
    padding: 34px 36px 30px;
    overflow: hidden;
    border: 1px solid var(--fc-border);
    border-radius: 5px;
    background: var(--fc-card);
    box-shadow: 0 28px 70px rgba(0, 0, 0, 0.38);
    transform-origin: center;
    transition:
        transform 620ms cubic-bezier(.22, 1, .36, 1),
        opacity 440ms ease,
        filter 440ms ease,
        border-color 440ms ease;
    will-change: transform, opacity;
}

.fc-testimonials__card[data-active="true"] {
    border-color: color-mix(in srgb, var(--fc-accent) 42%, var(--fc-border));
}

.fc-testimonials__author {
    display: flex;
    align-items: flex-start;
    min-width: 0;
}

.fc-testimonials__author-copy {
    flex: 1 1 auto;
    min-width: 0;
}

.fc-testimonials__name {
    display: flex;
    align-items: center;
    gap: 9px;
    margin: 0;
    overflow: hidden;
    color: var(--fc-text);
    font-size: 16px;
    font-weight: 600;
    line-height: 1.25;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.fc-testimonials__role {
    display: flex;
    align-items: center;
    gap: 9px;
    margin: 4px 0 0;
    overflow: hidden;
    color: var(--fc-muted);
    font-size: 13px;
    font-weight: 400;
    line-height: 1.3;
    letter-spacing: .01em;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.fc-testimonials__name > span,
.fc-testimonials__role > span {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.fc-testimonials__identity-icon {
    flex: 0 0 auto;
    width: 17px;
    height: 17px;
    color: var(--fc-accent);
}

.fc-testimonials__role .fc-testimonials__identity-icon {
    width: 15px;
    height: 15px;
    color: var(--fc-muted);
    opacity: .88;
}

.fc-testimonials__rating {
    flex: 0 0 auto;
    width: 110px;
    height: 21px;
    margin: 1px 0 0 20px;
    color: var(--fc-accent);
}

.fc-testimonials__divider {
    width: 100%;
    height: 1px;
    margin: 25px 0 30px;
    background: var(--fc-border);
}

.fc-testimonials__quote {
    margin: 0;
    color: var(--fc-text);
    font-size: clamp(22px, 2.1vw, 30px);
    font-weight: 430;
    line-height: 1.38;
    letter-spacing: -.025em;
}

.fc-testimonials__card-footer {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    margin-top: auto;
    padding-top: 22px;
}

.fc-testimonials__rule {
    width: 44px;
    height: 1px;
    margin-bottom: 5px;
    background: var(--fc-border);
}

.fc-testimonials__quote-mark {
    width: 34px;
    height: 26px;
    color: var(--fc-accent);
    opacity: .8;
}

.fc-testimonials__controls {
    position: absolute;
    z-index: 20;
    right: 24px;
    bottom: 22px;
    display: flex;
    align-items: center;
    gap: 8px;
}

.fc-testimonials__count {
    min-width: 54px;
    margin-right: 8px;
    color: var(--fc-muted);
    font-size: 12px;
    font-variant-numeric: tabular-nums;
    letter-spacing: .12em;
    text-align: right;
}

.fc-testimonials__button {
    display: grid;
    width: 44px;
    height: 44px;
    padding: 0;
    place-items: center;
    border: 1px solid color-mix(in srgb, var(--fc-accent) 58%, transparent);
    border-radius: 4px;
    color: #17130a;
    background: var(--fc-accent);
    cursor: pointer;
    transition: transform 180ms ease, filter 180ms ease, background 180ms ease;
    -webkit-tap-highlight-color: transparent;
}

.fc-testimonials__button:first-of-type {
    color: var(--fc-accent);
    background: color-mix(in srgb, var(--fc-accent) 26%, var(--fc-background));
}

.fc-testimonials__button:hover {
    filter: brightness(1.08);
    transform: translateY(-2px);
}

.fc-testimonials__button:active {
    transform: translateY(0) scale(.96);
}

.fc-testimonials__button:focus-visible {
    outline: 2px solid var(--fc-text);
    outline-offset: 2px;
}

.fc-testimonials__button:disabled {
    opacity: .45;
    cursor: default;
    filter: none;
    transform: none;
}

.fc-testimonials__button svg {
    width: 18px;
    height: 18px;
}

.fc-testimonials__empty {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    padding: 24px;
    color: var(--fc-muted);
    text-align: center;
}

.fc-testimonials__sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

@container (max-width: 600px) {
    .fc-testimonials__viewport {
        inset: 0 0 76px;
    }

    .fc-testimonials__card {
        width: calc(100% - 38px);
        height: min(var(--fc-card-height), calc(100% - 34px));
        padding: 24px 22px 22px;
    }

    .fc-testimonials__rating {
        width: 96px;
        height: 18px;
        margin-left: 14px;
    }

    .fc-testimonials__divider {
        margin: 21px 0 24px;
    }

    .fc-testimonials__quote {
        font-size: clamp(20px, 6.1vw, 26px);
    }

    .fc-testimonials__controls {
        right: 18px;
        bottom: 16px;
    }
}

@media (prefers-reduced-motion: reduce) {
    .fc-testimonials__card,
    .fc-testimonials__button {
        transition-duration: 0.01ms;
    }
}
`

function wrapIndex(value: number, length: number) {
    return ((value % length) + length) % length
}

function circularDistance(index: number, activeIndex: number, length: number) {
    let distance = index - activeIndex
    const halfway = length / 2

    if (distance > halfway) distance -= length
    if (distance < -halfway) distance += length

    return distance
}

function StarRating() {
    return (
        <svg
            className="fc-testimonials__rating"
            viewBox="0 0 84 16"
            fill="currentColor"
            role="img"
            aria-label="5 out of 5 stars"
        >
            {[0, 17, 34, 51, 68].map((x) => (
                <path
                    key={x}
                    transform={`translate(${x} 0)`}
                    d="M8 1.05 10.08 5.3l4.69.68-3.39 3.31.8 4.67L8 11.76l-4.18 2.2.8-4.67-3.39-3.31 4.69-.68L8 1.05Z"
                />
            ))}
        </svg>
    )
}

function PersonIcon() {
    return (
        <svg
            className="fc-testimonials__identity-icon"
            viewBox="0 0 18 18"
            fill="none"
            aria-hidden="true"
        >
            <circle cx="9" cy="5.1" r="3" stroke="currentColor" strokeWidth="1.45" />
            <path
                d="M3.4 15.4c.5-3.1 2.35-4.65 5.6-4.65s5.1 1.55 5.6 4.65"
                stroke="currentColor"
                strokeWidth="1.45"
                strokeLinecap="round"
            />
        </svg>
    )
}

function BusinessIcon() {
    return (
        <svg
            className="fc-testimonials__identity-icon"
            viewBox="0 0 18 18"
            fill="none"
            aria-hidden="true"
        >
            <path
                d="M3 15.5v-12h8v12m0-8h4v8M1.5 15.5h15M5.5 6.5h3M5.5 9.5h3M5.5 12.5h3M13 10.25h.01M13 13h.01"
                stroke="currentColor"
                strokeWidth="1.35"
                strokeLinecap="round"
                strokeLinejoin="round"
            />
        </svg>
    )
}

function ArrowIcon({ direction }: { direction: "left" | "right" }) {
    return (
        <svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
            <path
                d={direction === "left" ? "m12.5 4.5-5.5 5.5 5.5 5.5" : "m7.5 4.5 5.5 5.5-5.5 5.5"}
                stroke="currentColor"
                strokeWidth="1.7"
                strokeLinecap="round"
                strokeLinejoin="round"
            />
        </svg>
    )
}

function QuoteMark() {
    return (
        <svg
            className="fc-testimonials__quote-mark"
            viewBox="0 0 34 26"
            fill="currentColor"
            aria-hidden="true"
        >
            <path d="M0 16.4C0 8.9 3.4 3.5 10.2 0l2.1 3.5C8.4 5.8 6.4 8.4 6.2 11.3h5.4V26H0V16.4Zm21.7 0c0-7.5 3.4-12.9 10.2-16.4L34 3.5c-3.9 2.3-5.9 4.9-6.1 7.8h5.4V26H21.7v-9.6Z" />
        </svg>
    )
}

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 620
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight any
 */
export default function TestimonialsSlider(props: Props) {
    const {
        testimonials = DEFAULT_TESTIMONIALS,
        autoPlay = true,
        autoPlayDelay = 5,
        background = "#080806",
        cardColor = "#12110F",
        cardBorder = "#302D27",
        textColor = "#F5F3EE",
        mutedColor = "#AAA59B",
        accentColor = "#FF5500",
        cardWidth = 520,
        cardHeight = 400,
        font,
        style,
    } = props

    const items = testimonials ?? []
    const itemCount = items.length
    const [activeIndex, setActiveIndex] = React.useState(0)
    const [isPaused, setIsPaused] = React.useState(false)
    const pointerStart = React.useRef<number | null>(null)
    const safeIndex = itemCount > 0 ? wrapIndex(activeIndex, itemCount) : 0

    const move = React.useCallback(
        (direction: number) => {
            if (itemCount < 2) return
            setActiveIndex((current) => wrapIndex(current + direction, itemCount))
        },
        [itemCount]
    )

    React.useEffect(() => {
        if (!autoPlay || isPaused || itemCount < 2) return

        const timer = window.setInterval(() => {
            setActiveIndex((current) => wrapIndex(current + 1, itemCount))
        }, Math.max(autoPlayDelay, 1) * 1000)

        return () => window.clearInterval(timer)
    }, [autoPlay, autoPlayDelay, isPaused, itemCount])

    const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
        if (event.key === "ArrowLeft") {
            event.preventDefault()
            move(-1)
        }
        if (event.key === "ArrowRight") {
            event.preventDefault()
            move(1)
        }
    }

    const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
        if ((event.target as Element).closest("button")) {
            pointerStart.current = null
            return
        }
        pointerStart.current = event.clientX
    }

    const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
        const start = pointerStart.current
        pointerStart.current = null
        if (start === null) return

        const distance = event.clientX - start
        if (Math.abs(distance) < 44) return
        move(distance < 0 ? 1 : -1)
    }

    const rootStyle = {
        ...font,
        ...style,
        "--fc-background": background,
        "--fc-card": cardColor,
        "--fc-border": cardBorder,
        "--fc-text": textColor,
        "--fc-muted": mutedColor,
        "--fc-accent": accentColor,
        "--fc-card-width": `${cardWidth}px`,
        "--fc-card-height": `${cardHeight}px`,
    } as React.CSSProperties

    return (
        <section
            className="fc-testimonials"
            style={rootStyle}
            role="region"
            aria-roledescription="carousel"
            aria-label="Client testimonials"
            tabIndex={0}
            onKeyDown={handleKeyDown}
            onPointerDown={handlePointerDown}
            onPointerUp={handlePointerUp}
            onPointerCancel={() => {
                pointerStart.current = null
            }}
            onMouseEnter={() => setIsPaused(true)}
            onMouseLeave={() => setIsPaused(false)}
            onFocus={() => setIsPaused(true)}
            onBlur={(event) => {
                if (!event.currentTarget.contains(event.relatedTarget)) setIsPaused(false)
            }}
        >
            <style>{CSS}</style>
            <div className="fc-testimonials__grid" aria-hidden="true" />

            {itemCount > 0 ? (
                <>
                    <div className="fc-testimonials__viewport">
                        {items.map((item, index) => {
                            const distance = circularDistance(index, safeIndex, itemCount)
                            const absoluteDistance = Math.abs(distance)
                            const isActive = distance === 0
                            const translate = distance * 76
                            const scale = isActive ? 1 : Math.max(0.82, 0.94 - (absoluteDistance - 1) * 0.05)
                            const opacity = isActive ? 1 : Math.max(0.1, 0.5 - (absoluteDistance - 1) * 0.18)
                            const blur = isActive ? 0 : Math.min(4, 1 + (absoluteDistance - 1) * 1.25)

                            return (
                                <article
                                    key={`${item.name}-${index}`}
                                    className="fc-testimonials__card"
                                    data-active={isActive}
                                    aria-hidden={!isActive}
                                    style={{
                                        zIndex: Math.max(1, 10 - absoluteDistance),
                                        opacity,
                                        filter: `blur(${blur}px)`,
                                        pointerEvents: isActive ? "auto" : "none",
                                        transform: `translate3d(calc(-50% + ${translate}%), -50%, 0) scale(${scale})`,
                                    }}
                                >
                                    <header className="fc-testimonials__author">
                                        <div className="fc-testimonials__author-copy">
                                            <h3 className="fc-testimonials__name">
                                                <PersonIcon />
                                                <span>{item.name}</span>
                                            </h3>
                                            <p className="fc-testimonials__role">
                                                <BusinessIcon />
                                                <span>{item.role}</span>
                                            </p>
                                        </div>
                                        <StarRating />
                                    </header>

                                    <div className="fc-testimonials__divider" />
                                    <blockquote className="fc-testimonials__quote">{item.quote}</blockquote>

                                    <footer className="fc-testimonials__card-footer" aria-hidden="true">
                                        <span className="fc-testimonials__rule" />
                                        <QuoteMark />
                                    </footer>
                                </article>
                            )
                        })}
                    </div>

                    <div className="fc-testimonials__controls">
                        <span className="fc-testimonials__count" aria-hidden="true">
                            {String(safeIndex + 1).padStart(2, "0")} / {String(itemCount).padStart(2, "0")}
                        </span>
                        <button
                            className="fc-testimonials__button"
                            type="button"
                            aria-label="Previous testimonial"
                            disabled={itemCount < 2}
                            onClick={() => move(-1)}
                        >
                            <ArrowIcon direction="left" />
                        </button>
                        <button
                            className="fc-testimonials__button"
                            type="button"
                            aria-label="Next testimonial"
                            disabled={itemCount < 2}
                            onClick={() => move(1)}
                        >
                            <ArrowIcon direction="right" />
                        </button>
                    </div>

                    <p className="fc-testimonials__sr-only" aria-live="polite" aria-atomic="true">
                        Testimonial {safeIndex + 1} of {itemCount}: {items[safeIndex]?.quote}
                    </p>
                </>
            ) : (
                <p className="fc-testimonials__empty">Add at least one testimonial in the properties panel.</p>
            )}
        </section>
    )
}

TestimonialsSlider.displayName = "Testimonial Slider"

addPropertyControls(TestimonialsSlider, {
    testimonials: {
        type: ControlType.Array,
        title: "Testimonials",
        maxCount: 10,
        defaultValue: DEFAULT_TESTIMONIALS,
        control: {
            type: ControlType.Object,
            controls: {
                name: {
                    type: ControlType.String,
                    title: "Name",
                    defaultValue: "Client Name",
                },
                role: {
                    type: ControlType.String,
                    title: "Role",
                    defaultValue: "Custom Home Client",
                },
                quote: {
                    type: ControlType.String,
                    title: "Quote",
                    displayTextArea: true,
                    defaultValue: "Add the client testimonial here.",
                },
            },
        },
    },
    autoPlay: {
        type: ControlType.Boolean,
        title: "Autoplay",
        defaultValue: true,
        enabledTitle: "On",
        disabledTitle: "Off",
    },
    autoPlayDelay: {
        type: ControlType.Number,
        title: "Delay",
        defaultValue: 5,
        min: 1,
        max: 15,
        step: 0.5,
        unit: "s",
        hidden: (props) => !props.autoPlay,
    },
    cardWidth: {
        type: ControlType.Number,
        title: "Card Width",
        defaultValue: 520,
        min: 280,
        max: 760,
        step: 10,
        unit: "px",
    },
    cardHeight: {
        type: ControlType.Number,
        title: "Card Height",
        defaultValue: 400,
        min: 300,
        max: 560,
        step: 10,
        unit: "px",
    },
    background: {
        type: ControlType.Color,
        title: "Background",
        defaultValue: "#080806",
    },
    cardColor: {
        type: ControlType.Color,
        title: "Card",
        defaultValue: "#12110F",
    },
    cardBorder: {
        type: ControlType.Color,
        title: "Border",
        defaultValue: "#302D27",
    },
    textColor: {
        type: ControlType.Color,
        title: "Text",
        defaultValue: "#F5F3EE",
    },
    mutedColor: {
        type: ControlType.Color,
        title: "Muted Text",
        defaultValue: "#AAA59B",
    },
    accentColor: {
        type: ControlType.Color,
        title: "Accent",
        defaultValue: "#FF5500",
    },
    font: {
        type: ControlType.Font,
        title: "Font",
        controls: "extended",
        defaultFontType: "sans-serif",
    },
})
  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.