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.
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. In Framer: Assets → Code → Create Code File.
- 2. Delete the placeholder and paste this entire file — it already carries its default export and addPropertyControls.