Custom Framer Component · Free, unlicensed
Showcase Section
A three-variant visual showcase section (cross-fade, card row, or evidence layout) for walking through the details of your work.
showcasecarouselgallerybefore-after
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 |
|---|---|---|
| Variant | variant | Enum |
| Slides | /* Cross-fade + Card row */ slides | Array |
| Autoplay | autoplay | Boolean |
| Every | autoplaySeconds | Number |
| Hover selects | selectOnHover | Boolean |
| Copy + arrow | detailsPosition | Enum |
| Heading | /* Card row + Evidence */ heading | String |
| Photo | /* Evidence */ image | ResponsiveImage |
| Rule | showRule | Boolean |
| Paragraph 1 | bodyOne | String |
| Paragraph 2 | bodyTwo | String |
| Thumbnails | thumbs | Array |
| Wash | /* Appearance */ washStrength | Number |
| Wash colour | charcoal | Color |
| Background | background | Color |
| Ink | ink | Color |
| Radius | radius | Number |
| Accent | accent | Color |
Component source
The complete, unmodified file — copy the whole thing.
import { addPropertyControls, ControlType } from "framer"
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
export const META = {
slug: "showcase-section",
title: "Showcase Section",
description: "A three-variant visual showcase section (cross-fade, card row, or evidence layout) for walking through the details of your work.",
tags: ["showcase", "carousel", "gallery", "before-after"],
category: "custom-framer",
}
/**
* A "look closer" showcase section. One component, three selectable treatments:
*
* Cross-fade (study 01) Full-bleed photo, copy over the top, dots and arrow.
* Card row (study 02) Light section, headline, a row of photo cards, copy below.
* Evidence (study 03) Full-bleed photo, questions left, example thumbnails right.
*
* They are one file on purpose. Three near-identical component files would be a fork, and
* the one that misses the next edit silently falls behind. Cross-fade and Card row share the
* same Slides array and the same autoplay driver, so they cannot drift on content either.
*
* WIDTH IS LOCKED TO `fixed` — do not change it. Per Framer's docs `fixed` means the
* component "can fill 100% of its size", while `auto` means it "will dictate its own size
* based on its content". All three treatments are built on `width: 100%`, which only resolves
* under `fixed`. In `auto` the container shrink-wraps, 100% computes to 0, and the section
* collapses to a thin vertical strip. Each root also carries a `min-width: 320px` floor as a
* second line of defence.
*
* Images are Framer-hosted image props, never file paths — a code component cannot resolve
* relative asset paths, so anything pointing at an assets folder breaks on publish.
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight any-prefer-fixed
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 640
*/
export default function ShowcaseSection(props) {
const { variant = "cards" } = props
if (variant === "cards") return <CardRow {...props} />
if (variant === "evidence") return <Evidence {...props} />
return <CrossFade {...props} />
}
/* Shared autoplay + pause driver. Used by Cross-fade and Card row; Evidence is static. */
function useCarousel({ count, autoplay, autoplaySeconds }) {
const [current, setCurrent] = useState(0)
const timer = useRef(null)
const reduced = useMemo(
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
[]
)
// Autoplay is a convenience, never the only way through, and never for someone who has
// asked the OS to reduce motion.
const start = useCallback(() => {
window.clearInterval(timer.current)
if (!autoplay || reduced || count < 2) return
timer.current = window.setInterval(
() => setCurrent((c) => (c + 1) % count),
Math.max(1.5, autoplaySeconds) * 1000
)
}, [autoplay, autoplaySeconds, reduced, count])
const stop = useCallback(() => window.clearInterval(timer.current), [])
useEffect(() => {
start()
return stop
}, [start, stop, current])
const go = useCallback(
(next) => {
if (count === 0) return
setCurrent(((next % count) + count) % count)
},
[count]
)
return { current, go, start, stop }
}
function Empty({ style, what }) {
return (
<div style={{ ...style, display: "grid", placeItems: "center", color: "#888", minHeight: 120 }}>
Add {what} in the properties panel.
</div>
)
}
function CrossFade(props) {
const {
slides = DEFAULT_SLIDES,
autoplay = true,
autoplaySeconds = 6.5,
accent = "#F97316",
charcoal = "#2F3A48",
washStrength = 58,
radius = 0,
style,
} = props
const scope = "fcs" + useId().replace(/[^a-zA-Z0-9]/g, "")
const count = slides.length
const { current, go, start, stop } = useCarousel({ count, autoplay, autoplaySeconds })
const swipeX = useRef(null)
if (count === 0) return <Empty style={style} what="slides" />
return (
<div className={scope} style={{ ...style, borderRadius: radius, overflow: "hidden" }}>
<style>{cssFade(scope, { accent, charcoal, washStrength, radius })}</style>
<section
className="fc-slider"
aria-roledescription="carousel"
aria-label="A closer look"
onPointerEnter={stop}
onPointerLeave={start}
onFocus={stop}
onBlur={start}
onKeyDown={(e) => {
if (e.key === "ArrowRight") { e.preventDefault(); go(current + 1) }
if (e.key === "ArrowLeft") { e.preventDefault(); go(current - 1) }
}}
>
<div
className="fc-slide-track"
onPointerDown={(e) => { swipeX.current = e.clientX }}
onPointerUp={(e) => {
if (swipeX.current === null) return
const dx = e.clientX - swipeX.current
if (Math.abs(dx) > 45) go(current + (dx < 0 ? 1 : -1))
swipeX.current = null
}}
>
{slides.map((slide, i) => (
<article
key={i}
className={"fc-slide" + (i === current ? " is-active" : "")}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${count}: ${slide.label || slide.title}`}
aria-hidden={i !== current}
>
<div className="fc-slide-photo">
{slide.image?.src ? (
<img src={slide.image.src} srcSet={slide.image.srcSet} alt="" loading="lazy" decoding="async" />
) : null}
</div>
<div className="fc-slide-body">
<h2 className="fc-slide-title">{slide.title}</h2>
<p className="fc-slide-copy">{slide.copy}</p>
<div className="fc-slider-controls">
<div className="fc-dots" role="tablist" aria-label="Choose a slide">
{slides.map((s2, j) => (
<button
key={j}
className={"fc-dot" + (j === current ? " is-active" : "")}
type="button"
role="tab"
aria-selected={j === current}
tabIndex={i === current ? 0 : -1}
onClick={() => go(j)}
>
<span className="fc-dot-fill" />
<span className="fc-sr">{s2.label || s2.title}</span>
</button>
))}
</div>
<button
className="fc-next"
type="button"
aria-label="Next slide"
tabIndex={i === current ? 0 : -1}
onClick={() => go(current + 1)}
>
<svg viewBox="0 0 44 16" fill="none" aria-hidden="true">
<path d="M0 8h40M33 1l7 7-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
</article>
))}
</div>
</section>
</div>
)
}
function CardRow(props) {
const {
heading = "The details most people miss",
slides = DEFAULT_SLIDES,
autoplay = true,
autoplaySeconds = 6.5,
background = "#F4F5F6",
ink = "#2F3A48",
accent = "#F97316",
selectOnHover = true,
detailsPosition = "below",
style,
} = props
const scope = "fcc" + useId().replace(/[^a-zA-Z0-9]/g, "")
const count = slides.length
const { current, go, start, stop } = useCarousel({ count, autoplay, autoplaySeconds })
if (count === 0) return <Empty style={style} what="cards" />
const active = slides[Math.min(current, count - 1)]
const details = (
<div className="fc-cards-foot">
{/* key forces the fade to replay whenever the copy changes. */}
<p className="fc-cards-copy" key={current} aria-live="polite">
{active?.copy}
</p>
<button className="fc-next" type="button" aria-label="Next card" onClick={() => go(current + 1)}>
<svg viewBox="0 0 44 16" fill="none" aria-hidden="true">
<path d="M0 8h40M33 1l7 7-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
)
return (
<div className={scope} style={style}>
<style>{cssCards(scope, { background, ink, accent })}</style>
<section
className="fc-cards"
data-details={detailsPosition}
aria-roledescription="carousel"
aria-label={heading}
onPointerEnter={stop}
onPointerLeave={start}
onFocus={stop}
onBlur={start}
onKeyDown={(e) => {
if (e.key === "ArrowRight") { e.preventDefault(); go(current + 1) }
if (e.key === "ArrowLeft") { e.preventDefault(); go(current - 1) }
}}
>
<h2 className="fc-cards-heading">{heading}</h2>
{detailsPosition === "above" ? details : null}
<div className="fc-card-row" role="tablist" aria-label="Choose an example">
{slides.map((slide, i) => (
<button
key={i}
className={"fc-card" + (i === current ? " is-active" : "")}
type="button"
role="tab"
aria-selected={i === current}
onClick={() => go(i)}
onPointerEnter={() => selectOnHover && go(i)}
>
<span className="fc-card-photo">
{slide.image?.src ? (
<img src={slide.image.src} srcSet={slide.image.srcSet} alt="" loading="lazy" decoding="async" />
) : null}
</span>
<span className="fc-card-label">{slide.card}</span>
</button>
))}
</div>
{detailsPosition === "above" ? null : details}
</section>
</div>
)
}
function Evidence(props) {
const {
image,
heading = "The details most people miss",
bodyOne = "",
bodyTwo = "",
thumbs = [],
charcoal = "#2F3A48",
washStrength = 72,
showRule = true,
style,
} = props
const scope = "fce" + useId().replace(/[^a-zA-Z0-9]/g, "")
return (
<div className={scope} style={style}>
<style>{cssEvidence(scope, { charcoal, washStrength, showRule })}</style>
<section className="fc-story">
<div className="fc-story-photo">
{image?.src ? (
<img src={image.src} srcSet={image.srcSet} alt="" decoding="async" />
) : null}
</div>
<div className="fc-story-inner">
<div className="fc-story-body">
<h2 className="fc-story-heading">{heading}</h2>
<div className="fc-story-copy">
{bodyOne ? <p>{bodyOne}</p> : null}
{bodyTwo ? <p>{bodyTwo}</p> : null}
</div>
</div>
{thumbs.length > 0 && (
<div className="fc-story-thumbs" aria-label="Examples of what we find">
{thumbs.map((thumb, i) => (
<span key={i} className="fc-thumb">
{thumb.image?.src ? (
<img src={thumb.image.src} srcSet={thumb.image.srcSet} alt={thumb.alt || ""} loading="lazy" decoding="async" />
) : null}
</span>
))}
</div>
)}
</div>
</section>
</div>
)
}
function cssFade(scope, { accent, charcoal, washStrength, radius }) {
const w = Math.max(0, Math.min(100, washStrength))
return `
/* A shrink-to-fit parent makes \`width: 100%\` compute to 0, which collapses every container
query to its narrowest step - this is what squished Framer's split code preview. The
min-width is the floor; Framer overrides the width on canvas. */
.${scope} { container: fc-showcase-shell / inline-size; width: 100%; min-width: 320px; height: 100%; }
.${scope} .fc-slider {
--fc-content-max: 1356px;
--fc-gutter: 30px;
--fc-pad: max(var(--fc-gutter), calc((100% - var(--fc-content-max)) / 2 + var(--fc-gutter)));
position: relative;
container: fc-slider / inline-size;
width: 100%;
height: 100%;
min-height: clamp(460px, 62cqi, 700px);
overflow: hidden;
border-radius: ${radius}px;
color: #fff;
font-stretch: 100%;
-webkit-font-smoothing: antialiased;
isolation: isolate;
}
.${scope} .fc-slider *, .${scope} .fc-slider *::before, .${scope} .fc-slider *::after { box-sizing: border-box; }
.${scope} .fc-slide-track { position: relative; display: grid; min-height: inherit; height: 100%; }
.${scope} .fc-slide {
grid-area: 1 / 1;
position: relative;
display: flex;
align-items: center;
min-height: inherit;
margin: 0;
padding: clamp(56px, 9cqi, 120px) var(--fc-pad);
opacity: 0;
visibility: hidden;
transition: opacity 700ms ease, visibility 700ms;
}
.${scope} .fc-slide.is-active { opacity: 1; visibility: visible; }
.${scope} .fc-slide-photo {
position: absolute;
inset: 0;
z-index: -1;
overflow: hidden;
transform: scale(1.06);
transition: transform 9s cubic-bezier(.2, 0, .2, 1);
}
.${scope} .fc-slide.is-active .fc-slide-photo { transform: scale(1); }
.${scope} .fc-slide-photo img { display: block; width: 100%; height: 100%; object-fit: cover; }
.${scope} .fc-slide-photo::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(95deg,
color-mix(in srgb, ${charcoal} ${w}%, transparent) 0%,
color-mix(in srgb, ${charcoal} ${Math.round(w * 0.59)}%, transparent) 50%,
color-mix(in srgb, ${charcoal} ${Math.round(w * 0.24)}%, transparent) 100%),
color-mix(in srgb, ${charcoal} ${Math.round(w * 0.31)}%, transparent);
}
/* Width is set against the container, not in \`ch\` — \`ch\` here would resolve against the
inherited 16px rather than the heading's own size and wrap the title to a narrow column. */
.${scope} .fc-slide-body { position: relative; max-width: min(62cqi, 720px); }
.${scope} .fc-slide-title {
margin: 0;
font-size: clamp(30px, 5.2cqi, 56px);
font-weight: 600;
line-height: 1.08;
letter-spacing: -.035em;
text-wrap: balance;
}
.${scope} .fc-slide-copy {
margin: clamp(16px, 2.2cqi, 26px) 0 0;
max-width: min(46cqi, 540px);
color: color-mix(in srgb, #fff 88%, ${accent});
font-size: clamp(15px, 1.9cqi, 21px);
line-height: 1.5;
}
.${scope} .fc-slide.is-active .fc-slide-title { animation: ${scope}rise 700ms cubic-bezier(.22, 1, .36, 1) both; }
.${scope} .fc-slide.is-active .fc-slide-copy { animation: ${scope}rise 700ms cubic-bezier(.22, 1, .36, 1) 90ms both; }
@keyframes ${scope}rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
/* In the flow, directly under the paragraph - not pinned to the bottom of the section. */
.${scope} .fc-slider-controls {
display: flex;
align-items: center;
gap: clamp(22px, 3.4cqi, 44px);
margin-top: clamp(24px, 3.2cqi, 40px);
}
.${scope} .fc-dots { display: flex; align-items: center; gap: clamp(10px, 1.4cqi, 16px); }
.${scope} .fc-dot {
position: relative;
width: 17px; height: 17px;
padding: 0;
border: 1.6px solid #fff;
border-radius: 50%;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.${scope} .fc-dot-fill {
position: absolute;
inset: 2px;
border-radius: 50%;
background: #fff;
transform: scale(0);
transition: transform 300ms cubic-bezier(.22, 1, .36, 1);
}
.${scope} .fc-dot.is-active .fc-dot-fill { transform: scale(1); }
.${scope} .fc-dot:hover .fc-dot-fill { transform: scale(.55); }
.${scope} .fc-dot.is-active:hover .fc-dot-fill { transform: scale(1); }
.${scope} .fc-next {
display: grid; place-items: center;
width: clamp(54px, 7cqi, 88px); height: 40px;
padding: 0; border: 0;
background: transparent; color: #fff; cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.${scope} .fc-next svg { width: 100%; height: auto; transition: transform 280ms cubic-bezier(.22, 1, .36, 1); }
.${scope} .fc-next:hover svg { transform: translateX(6px); }
.${scope} .fc-dot:focus-visible, .${scope} .fc-next:focus-visible { outline: 2px solid ${accent}; outline-offset: 4px; }
.${scope} .fc-sr {
position: absolute; width: 1px; height: 1px;
margin: -1px; padding: 0; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
/* Narrow means the copy runs the full width, so the left-weighted wash no longer sits under
all of it. Swap to a bottom-up scrim and drop the copy down to meet it. */
@container fc-slider (max-width: 620px) {
.${scope} .fc-slide-body, .${scope} .fc-slide-copy { max-width: none; }
.${scope} .fc-slider-controls { gap: 20px; }
.${scope} .fc-dot { width: 14px; height: 14px; }
.${scope} .fc-slide { align-items: flex-end; padding-bottom: clamp(40px, 8cqi, 70px); }
.${scope} .fc-slide-photo::after {
background:
linear-gradient(0deg,
color-mix(in srgb, ${charcoal} ${Math.min(100, Math.round(w * 1.52))}%, transparent) 0%,
color-mix(in srgb, ${charcoal} ${Math.round(w * 1.07)}%, transparent) 42%,
color-mix(in srgb, ${charcoal} ${Math.round(w * 0.31)}%, transparent) 100%),
color-mix(in srgb, ${charcoal} ${Math.round(w * 0.38)}%, transparent);
}
}
@container fc-showcase-shell (max-width: 767px) {
.${scope} .fc-slider { --fc-gutter: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.${scope} .fc-slide, .${scope} .fc-slide-photo, .${scope} .fc-dot-fill, .${scope} .fc-next svg { transition-duration: 1ms; }
.${scope} .fc-slide.is-active .fc-slide-title, .${scope} .fc-slide.is-active .fc-slide-copy { animation: none; }
.${scope} .fc-slide-photo, .${scope} .fc-slide.is-active .fc-slide-photo { transform: none; }
}
`
}
function cssCards(scope, { background, ink, accent }) {
return `
/* A shrink-to-fit parent makes \`width: 100%\` compute to 0, which collapses every container
query to its narrowest step - this is what squished Framer's split code preview. The
min-width is the floor; Framer overrides the width on canvas. */
.${scope} { container: fc-showcase-shell / inline-size; width: 100%; min-width: 320px; }
.${scope} .fc-cards {
--fc-content-max: 1356px;
--fc-gutter: 30px;
--fc-edge: max(var(--fc-gutter), calc((100% - var(--fc-content-max)) / 2 + var(--fc-gutter)));
container: fc-cards / inline-size;
box-sizing: border-box;
width: 100%;
padding: clamp(46px, 7cqi, 96px) var(--fc-edge) clamp(52px, 8cqi, 104px);
background: ${background};
color: ${ink};
font-stretch: 100%;
-webkit-font-smoothing: antialiased;
}
.${scope} .fc-cards *, .${scope} .fc-cards *::before, .${scope} .fc-cards *::after { box-sizing: border-box; }
.${scope} .fc-cards-heading {
margin: 0 0 clamp(20px, 2.6cqi, 34px);
max-width: 28ch;
font-size: clamp(24px, 3.1cqi, 38px);
font-weight: 600;
line-height: 1.12;
letter-spacing: -.03em;
text-wrap: balance;
}
.${scope} .fc-card-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: clamp(10px, 1.4cqi, 18px);
}
.${scope} .fc-card {
position: relative;
display: block;
aspect-ratio: 3 / 2;
padding: 0; border: 0;
border-radius: clamp(8px, 1cqi, 13px);
background: none;
overflow: hidden;
cursor: pointer;
font: inherit;
text-align: left;
-webkit-tap-highlight-color: transparent;
transition: transform 260ms cubic-bezier(.22, 1, .36, 1), box-shadow 260ms ease;
}
/* Inactive cards sit back; the selected one comes forward in colour, not just in scale. */
.${scope} .fc-card-photo {
position: absolute;
inset: 0;
overflow: hidden;
filter: saturate(.78) brightness(.95);
transform: scale(1.02);
transition: filter 320ms ease, transform 620ms cubic-bezier(.22, 1, .36, 1);
}
.${scope} .fc-card-photo img { display: block; width: 100%; height: 100%; object-fit: cover; }
.${scope} .fc-card-photo::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(0deg,
color-mix(in srgb, ${ink} 78%, transparent) 0%,
color-mix(in srgb, ${ink} 30%, transparent) 46%,
color-mix(in srgb, ${ink} 10%, transparent) 100%);
}
.${scope} .fc-card-label {
position: absolute;
left: clamp(10px, 1.3cqi, 16px);
right: clamp(10px, 1.3cqi, 16px);
bottom: clamp(9px, 1.2cqi, 15px);
color: #fff;
font-size: clamp(11px, 1.15cqi, 14px);
font-weight: 700;
line-height: 1.2;
letter-spacing: .06em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 10px color-mix(in srgb, ${ink} 60%, transparent);
}
.${scope} .fc-card.is-active { transform: translateY(-4px); box-shadow: 0 14px 34px color-mix(in srgb, ${ink} 26%, transparent); }
.${scope} .fc-card.is-active .fc-card-photo { filter: none; transform: scale(1); }
.${scope} .fc-card:focus-visible { outline: 2px solid ${accent}; outline-offset: 3px; }
/* Same arrow as Cross-fade, sat beside the copy so the row reads as one control group. */
.${scope} .fc-cards-foot {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: clamp(20px, 3cqi, 44px);
}
.${scope} .fc-next {
flex: 0 0 auto;
display: grid;
place-items: center;
width: clamp(48px, 6cqi, 76px);
height: 40px;
margin-top: clamp(20px, 2.6cqi, 34px);
padding: 0;
border: 0;
background: transparent;
color: ${ink};
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.${scope} .fc-next svg { width: 100%; height: auto; transition: transform 280ms cubic-bezier(.22, 1, .36, 1); }
.${scope} .fc-next:hover svg { transform: translateX(6px); }
.${scope} .fc-next:focus-visible { outline: 2px solid ${accent}; outline-offset: 4px; }
.${scope} .fc-cards-copy {
max-width: 52ch;
margin: clamp(20px, 2.6cqi, 34px) 0 0;
flex: 1 1 auto;
color: color-mix(in srgb, ${ink} 82%, #fff);
font-size: clamp(15px, 1.5cqi, 19px);
line-height: 1.55;
animation: ${scope}copy 420ms ease both;
}
.${scope} .fc-cards[data-details="above"] .fc-cards-foot {
margin-bottom: clamp(20px, 2.6cqi, 34px);
}
.${scope} .fc-cards[data-details="above"] .fc-cards-copy,
.${scope} .fc-cards[data-details="above"] .fc-next { margin-top: 0; }
@keyframes ${scope}copy { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* The reference layout is deliberately two rows of three on desktop, then two on mobile. */
@container fc-cards (max-width: 900px) { .${scope} .fc-card-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@container fc-cards (max-width: 520px) {
.${scope} .fc-card-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.${scope} .fc-cards-heading { max-width: none; }
.${scope} .fc-cards-foot { flex-direction: column; gap: 0; }
.${scope} .fc-next { margin-top: 18px; }
}
@container fc-showcase-shell (max-width: 767px) {
.${scope} .fc-cards { --fc-gutter: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.${scope} .fc-card, .${scope} .fc-card-photo { transition-duration: 1ms; }
.${scope} .fc-card.is-active { transform: none; }
.${scope} .fc-cards-copy { animation: none; }
}
`
}
function cssEvidence(scope, { charcoal, washStrength, showRule }) {
const s = "." + scope
const w = Math.max(0, Math.min(100, washStrength))
return `
/* A shrink-to-fit parent makes \`width: 100%\` compute to 0, which collapses every container
query to its narrowest step - this is what squished Framer's split code preview. The
min-width is the floor; Framer overrides the width on canvas. */
${s} { container: fc-showcase-shell / inline-size; width: 100%; min-width: 320px; height: 100%; }
${s} .fc-story {
--fc-content-max: 1356px;
--fc-gutter: 30px;
--fc-pad: max(var(--fc-gutter), calc((100% - var(--fc-content-max)) / 2 + var(--fc-gutter)));
position: relative;
container: fc-story / inline-size;
width: 100%;
height: 100%;
overflow: hidden;
color: #fff;
font-stretch: 100%;
-webkit-font-smoothing: antialiased;
isolation: isolate;
}
${s} .fc-story *, ${s} .fc-story *::before, ${s} .fc-story *::after { box-sizing: border-box; }
${s} .fc-story-photo { position: absolute; inset: 0; z-index: -1; overflow: hidden; }
${s} .fc-story-photo img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* Charcoal wash, a little heavier on the left so the questions stay legible over the photo. */
${s} .fc-story-photo::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(95deg,
color-mix(in srgb, ${charcoal} ${w}%, transparent) 0%,
color-mix(in srgb, ${charcoal} ${Math.round(w * 0.72)}%, transparent) 55%,
color-mix(in srgb, ${charcoal} ${Math.round(w * 0.44)}%, transparent) 100%),
color-mix(in srgb, ${charcoal} ${Math.round(w * 0.33)}%, transparent);
}
${s} .fc-story-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: clamp(28px, 5cqi, 80px);
padding: clamp(48px, 8cqi, 110px) var(--fc-pad);
height: 100%;
}
${s} .fc-story-body { max-width: min(62cqi, 700px); }
${s} .fc-story-heading {
margin: 0;
font-size: clamp(24px, 3.4cqi, 40px);
font-weight: 600;
line-height: 1.16;
letter-spacing: -.03em;
text-wrap: balance;
}
/* The rule is part of the text column, not a section divider — it stops short of the strip. */
${s} .fc-story-copy {
margin-top: clamp(16px, 2.2cqi, 26px);
padding-top: clamp(18px, 2.4cqi, 30px);
${showRule ? "border-top: 1px solid color-mix(in srgb, #fff 45%, transparent);" : ""}
}
${s} .fc-story-copy p {
margin: 0;
max-width: 62ch;
font-size: clamp(14px, 1.45cqi, 18px);
line-height: 1.6;
}
${s} .fc-story-copy p + p { margin-top: clamp(16px, 2cqi, 26px); }
${s} .fc-story-thumbs {
display: grid;
gap: clamp(8px, 1.1cqi, 14px);
width: clamp(120px, 18cqi, 220px);
}
${s} .fc-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
${s} .fc-thumb {
display: block;
overflow: hidden;
aspect-ratio: 3 / 2;
border-radius: clamp(7px, .9cqi, 12px);
box-shadow: 0 10px 30px color-mix(in srgb, ${charcoal} 40%, transparent);
}
/* The strip drops under the copy before it gets too narrow to read. */
@container fc-story (max-width: 760px) {
${s} .fc-story-inner { grid-template-columns: minmax(0, 1fr); gap: clamp(22px, 4cqi, 34px); }
${s} .fc-story-body { max-width: none; }
${s} .fc-story-thumbs { width: 100%; grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@container fc-story (max-width: 420px) { ${s} .fc-story-copy p { font-size: 13.5px; } }
@container fc-showcase-shell (max-width: 767px) {
${s} .fc-story { --fc-gutter: 15px; }
}
`
}
const DEFAULT_SLIDES = [
{
card: "Close inspection",
title: "\"It looks fine.\" Has anyone actually checked?",
copy: "The same shot, split down the middle. Every mark on the right is something that was already there.",
label: "The same subject, up close",
},
{
card: "Easy to miss",
title: "It is not only the obvious things",
copy: "Small faults and loose connections are the ones that cause problems long after the first look.",
label: "A small fault, easy to miss",
},
{
card: "Related parts",
title: "Connected parts take the hit too",
copy: "Everything nearby is checked in the same pass, because an issue rarely stops at the first thing you notice.",
label: "Related parts checked too",
},
{
card: "Early signs",
title: "It shows up elsewhere before you find the source",
copy: "A small sign somewhere else is usually the first thing people notice. By then the underlying issue has been building for a while.",
label: "An early warning sign",
},
{
card: "Left unchecked",
title: "Left unchecked, it spreads",
copy: "Once a small issue is left alone, it tends to grow. It becomes a bigger problem than it needed to be.",
label: "An issue left to spread",
},
{
card: "The full picture",
title: "Left long enough, it reaches the core",
copy: "The expensive end of a small, early issue is what happens when it is never addressed. This is what it turns into.",
label: "The full picture, eventually",
},
]
const isFade = (p) => (p.variant || "fade") === "fade"
const isCards = (p) => p.variant === "cards"
const isEvidence = (p) => p.variant === "evidence"
addPropertyControls(ShowcaseSection, {
variant: {
type: ControlType.Enum,
title: "Variant",
options: ["fade", "cards", "evidence"],
optionTitles: ["Cross-fade", "Card row", "Evidence"],
defaultValue: "cards",
displaySegmentedControl: true,
},
/* Cross-fade + Card row */
slides: {
type: ControlType.Array,
title: "Slides",
hidden: isEvidence,
control: {
type: ControlType.Object,
controls: {
image: { type: ControlType.ResponsiveImage, title: "Photo" },
title: { type: ControlType.String, title: "Heading", defaultValue: "" },
copy: { type: ControlType.String, title: "Copy", displayTextArea: true, defaultValue: "" },
card: {
type: ControlType.String,
title: "Card label",
defaultValue: "",
description: "Card row only. Shown uppercase on one line, so keep it short.",
},
label: { type: ControlType.String, title: "Screen-reader label", defaultValue: "" },
},
},
defaultValue: DEFAULT_SLIDES,
},
autoplay: { type: ControlType.Boolean, title: "Autoplay", defaultValue: true, hidden: isEvidence },
autoplaySeconds: {
type: ControlType.Number,
title: "Every",
min: 2,
max: 20,
step: 0.5,
unit: "s",
defaultValue: 6.5,
hidden: (p) => isEvidence(p) || !p.autoplay,
},
selectOnHover: { type: ControlType.Boolean, title: "Hover selects", defaultValue: true, hidden: (p) => !isCards(p) },
detailsPosition: {
type: ControlType.Enum,
title: "Copy + arrow",
options: ["below", "above"],
optionTitles: ["Below images", "Above images"],
defaultValue: "below",
displaySegmentedControl: true,
hidden: (p) => !isCards(p),
},
/* Card row + Evidence */
heading: {
type: ControlType.String,
title: "Heading",
displayTextArea: true,
defaultValue: "The details most people miss",
hidden: isFade,
},
/* Evidence */
image: { type: ControlType.ResponsiveImage, title: "Photo", hidden: (p) => !isEvidence(p) },
showRule: { type: ControlType.Boolean, title: "Rule", defaultValue: true, hidden: (p) => !isEvidence(p) },
bodyOne: {
type: ControlType.String,
title: "Paragraph 1",
displayTextArea: true,
hidden: (p) => !isEvidence(p),
defaultValue:
"Have you had this properly checked recently? Have you noticed small marks, loose connections, wear or changes in performance? Do you know what evidence you'd need if you decided to take it further?",
},
bodyTwo: {
type: ControlType.String,
title: "Paragraph 2",
displayTextArea: true,
hidden: (p) => !isEvidence(p),
defaultValue:
"A proper look gives you clarity. If we find something, we'll explain what we've seen and your available next steps.",
},
thumbs: {
type: ControlType.Array,
title: "Thumbnails",
hidden: (p) => !isEvidence(p),
control: {
type: ControlType.Object,
controls: {
image: { type: ControlType.ResponsiveImage, title: "Photo" },
alt: { type: ControlType.String, title: "Alt text", defaultValue: "", description: "What the photo actually shows." },
},
},
defaultValue: [{ alt: "" }, { alt: "" }, { alt: "" }],
},
/* Appearance */
washStrength: {
type: ControlType.Number,
title: "Wash",
min: 0,
max: 100,
step: 1,
unit: "%",
defaultValue: 58,
hidden: isCards,
description: "Charcoal overlay strength over the photo.",
},
charcoal: { type: ControlType.Color, title: "Wash colour", defaultValue: "#2F3A48", hidden: isCards },
background: { type: ControlType.Color, title: "Background", defaultValue: "#F4F5F6", hidden: (p) => !isCards(p) },
ink: { type: ControlType.Color, title: "Ink", defaultValue: "#2F3A48", hidden: (p) => !isCards(p) },
radius: { type: ControlType.Number, title: "Radius", min: 0, max: 48, step: 1, defaultValue: 0, hidden: (p) => !isFade(p) },
accent: {
type: ControlType.Color,
title: "Accent",
defaultValue: "#F97316",
hidden: isEvidence,
description: "Focus ring colour.",
},
})
- 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.