Components / Numbers and data
Mix Band
A band of photo slices sized by their share; point at or tap a slice to widen it and show its title, share and summary.
Props
#0E0E0D
#6E6E6B
#6E6E6B
#E6E5E1
#F5F4F1
#FFFFFF
Set in code: Slices. The preview fills these in with demo content.
Point at a slice, or tap it.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Mix Band shows up in the Insert menu, and its controls appear in the right panel.
// Mix Band · Layr Components
//
// A band of photo slices, each as wide as its share of the whole: a budget mix,
// a set of services or a team. Pointing at a slice, or tapping it, widens it and
// shows its title, share and summary underneath.
// Paste into a new code file in Framer. Place it as wide as the section; it sets
// its own height. A slice without a photo shows a plain tone.
import { addPropertyControls, ControlType, Link, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"
type Img = { src: string; srcSet?: string; alt?: string; positionX?: string; positionY?: string }
type Slice = { photo?: Img; title?: string; summary?: string; share?: number; link?: string; linkLabel?: string }
interface MixBandProps {
items: Slice[]
direction: "Across" | "Down"
openTo: number
bandHeight: number
labels: "All" | "Open slice only"
startsOpen: number
shareLabel: boolean
textRoom: number
fontFamily: string
scale: number
imageWidth: string
textColour: string
softColour: string
mutedColour: string
lineColour: string
tintColour: string
pageColour: string
style?: CSSProperties
}
const ITEMS: Slice[] = [
{ title: "Search", share: 40, summary: "Paid and organic search planned together, so the budget goes where people are already looking.", linkLabel: "More on this" },
{ title: "Social", share: 25, summary: "Short pieces made for the feed, tested every week and kept only when they earn their place.", linkLabel: "More on this" },
{ title: "Email", share: 20, summary: "Messages that follow what people did, sent when they are most likely to read them.", linkLabel: "More on this" },
{ title: "Brand", share: 15, summary: "The look, the words and the few rules that keep every channel sounding like one company.", linkLabel: "More on this" },
]
const E = "cubic-bezier(.3,.7,.2,1)"
const SHEET = `
.lyr-mix{position:relative;width:100%;pointer-events:none;-webkit-font-smoothing:antialiased}
.lyr-mix-ph{position:absolute;top:0;height:var(--lyr-mix-band);overflow:hidden;pointer-events:auto;cursor:pointer;background:var(--lyr-mix-tint);transition:left .7s ${E},width .7s ${E}}
.lyr-mix-ph img{position:absolute;top:0;left:50%;height:100%;max-width:none;transform:translateX(-50%);object-fit:cover;display:block;transition:width .7s ${E}}
.lyr-mix-empty{position:absolute;inset:0;background:linear-gradient(170deg,rgba(255,255,255,.09),rgba(255,255,255,0) 72%)}
.lyr-mix-ph:after{content:"";position:absolute;top:0;bottom:0;right:0;width:1px;background:var(--lyr-mix-page)}
.lyr-mix-lab{position:absolute;pointer-events:auto;transition:left .7s ${E},width .7s ${E},top .5s ${E}}
.lyr-mix-head{display:flex;align-items:baseline;gap:12px;cursor:pointer;outline-offset:4px}
.lyr-mix-title{font-size:calc(24px * var(--lyr-mix-s));line-height:1.15;font-weight:500;letter-spacing:-.03em;margin:0}
.lyr-mix-share{font-size:15px;line-height:1.2;letter-spacing:-.01em;color:var(--lyr-mix-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.lyr-mix-more{max-width:400px;margin-top:12px;font-size:16px;line-height:1.45;letter-spacing:-.012em;color:var(--lyr-mix-soft);opacity:0;visibility:hidden;transition:opacity .25s ease,visibility 0s linear .25s}
.lyr-mix-lab.lyr-mix-on .lyr-mix-more{opacity:1;visibility:visible;transition:opacity .45s ease .25s,visibility 0s}
.lyr-mix-link{display:inline-block;margin-top:14px;font-size:15px;font-weight:500;letter-spacing:-.01em;color:var(--lyr-mix-ink);text-decoration:none;border-bottom:1px solid currentColor;padding-bottom:3px;transition:opacity .2s ease}
.lyr-mix-link:hover{opacity:.62}
.lyr-mix-across .lyr-mix-lab{top:calc(var(--lyr-mix-band) + 20px);padding-right:24px;container-type:inline-size}
@container (min-width:228px){.lyr-mix-across .lyr-mix-more{opacity:1;visibility:visible;transition:opacity .45s ease .2s,visibility 0s}}
.lyr-mix-down .lyr-mix-lab{left:0;width:100%;border-bottom:1px solid var(--lyr-mix-line);padding:16px 0}
.lyr-mix-down .lyr-mix-head{justify-content:space-between}
.lyr-mix-down .lyr-mix-more{margin-top:10px;height:0;overflow:hidden}
.lyr-mix-down .lyr-mix-lab.lyr-mix-on .lyr-mix-more{height:auto}
.lyr-mix-across.lyr-mix-solo .lyr-mix-lab{opacity:0;visibility:hidden;transition:opacity .2s ease,visibility 0s linear .2s}
.lyr-mix-across.lyr-mix-solo .lyr-mix-lab.lyr-mix-on{opacity:1;visibility:visible;transition:opacity .4s ease .2s,visibility 0s}
.lyr-mix-across.lyr-mix-solo .lyr-mix-more{opacity:1;visibility:visible}
@media (prefers-reduced-motion:reduce){.lyr-mix-ph,.lyr-mix-ph img,.lyr-mix-lab,.lyr-mix-more{transition:none!important}}
`
/**
* @framerIntrinsicWidth 1120
* @framerIntrinsicHeight 570
*
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight auto
*/
export default function MixBand(props: MixBandProps) {
const {
items = ITEMS,
direction = "Across",
openTo = 46,
bandHeight = 380,
labels = "All",
startsOpen = 0,
shareLabel = true,
textRoom = 190,
fontFamily = "Inter",
scale = 1,
imageWidth = "50vw",
textColour = "#F2F0EA",
softColour = "#C9C6BE",
mutedColour = "#8B8880",
lineColour = "#262623",
tintColour = "#1B1B19",
pageColour = "#0A0A09",
style,
} = props
const isStatic = useIsStaticRenderer()
// -1 until someone points at a slice; 0 is "none open", 1 to n is the open slice.
const [picked, setPicked] = useState(-1)
const leave = useRef(0)
useEffect(() => () => window.clearTimeout(leave.current), [])
const list = Array.isArray(items) ? items.filter(Boolean) : []
if (!list.length) return null
const n = list.length
const down = String(direction).toLowerCase().includes("down")
const solo = String(labels).toLowerCase().includes("open")
const sh = list.map((it) => Math.max(0.5, Number(it.share) || 1))
const sum = sh.reduce((a, b) => a + b, 0)
const rest = sh.map((v) => (v / sum) * 100)
// On a phone (Down) the first slice starts open; with a mouse nothing is open until you point.
const first = Math.max(0, Math.min(n, Math.round(Number(startsOpen) || 0)))
const active = picked >= 0 ? picked : first || (down ? 1 : 0)
const open = Math.max(10, Math.min(90, Number(openTo) || 46))
let widths = rest
if (active) {
const wk = Math.max(rest[active - 1], open)
widths = rest.map((v, i) => (i === active - 1 ? wk : ((100 - wk) * sh[i]) / (sum - sh[active - 1] || 1)))
}
const lefts = widths.map((_, i) => widths.slice(0, i).reduce((a, b) => a + b, 0))
const set = (v: number) => setPicked(v)
const enter = (k: number) => (e: ReactPointerEvent) => {
if (e.pointerType !== "mouse") return
window.clearTimeout(leave.current)
set(k)
}
const out = (e: ReactPointerEvent) => {
if (e.pointerType !== "mouse" || first) return
window.clearTimeout(leave.current)
leave.current = window.setTimeout(() => set(0), 120)
}
const s = Number(scale) || 1
const band = Math.max(80, Number(bandHeight) || 380)
const room = Math.max(60, Number(textRoom) || 190)
const rowClosed = Math.round(24 * s * 1.15 + 33)
const rowOpen = rowClosed + room
const family = `"${String(fontFamily || "Inter").replace(/['"]/g, "")}", "Inter", system-ui, sans-serif`
const vars = {
"--lyr-mix-ink": textColour,
"--lyr-mix-soft": softColour,
"--lyr-mix-muted": mutedColour,
"--lyr-mix-line": lineColour,
"--lyr-mix-tint": tintColour,
"--lyr-mix-page": pageColour,
"--lyr-mix-s": s,
"--lyr-mix-band": band + "px",
} as CSSProperties
return (
<div
className={"lyr-mix " + (down ? "lyr-mix-down" : "lyr-mix-across") + (solo ? " lyr-mix-solo" : "")}
style={{ ...style, ...vars, color: textColour, fontFamily: family, height: down ? band + (n - 1) * rowClosed + rowOpen : band + room }}
>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
{list.map((it, i) => {
const k = i + 1
const img = it.photo && it.photo.src ? it.photo : null
const biggest = Math.max(rest[i], open)
return (
<div key={"p" + i} className="lyr-mix-ph" style={{ left: lefts[i] + "%", width: widths[i] + "%" }} onPointerEnter={enter(k)} onPointerLeave={out} onClick={() => set(k)}>
{img ? (
<img loading="lazy" decoding="async" sizes={imageWidth || "50vw"} srcSet={img.srcSet} src={img.src} alt={img.alt || ""} style={{ width: (biggest / Math.max(widths[i], 0.5)) * 100 + "%", objectPosition: `${img.positionX || "50%"} ${img.positionY || "50%"}` }} />
) : (
<i className="lyr-mix-empty" />
)}
</div>
)
})}
{list.map((it, i) => {
const k = i + 1
const on = active === k || (isStatic && k === 1)
const top = band + i * rowClosed + (active && active < k ? room : 0)
const place: CSSProperties = down ? { top } : solo ? { left: `min(${lefts[i]}%, calc(100% - 340px))`, width: "340px" } : { left: lefts[i] + "%", width: widths[i] + "%" }
return (
<div key={"l" + i} className={"lyr-mix-lab" + (on ? " lyr-mix-on" : "")} style={place} onPointerEnter={enter(k)} onPointerLeave={out} onFocus={() => set(k)}>
<div
className="lyr-mix-head"
role="button"
tabIndex={0}
aria-expanded={on}
onClick={() => set(k)}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return
e.preventDefault()
set(k)
}}
>
<h3 className="lyr-mix-title">{it.title}</h3>
{shareLabel === false ? null : <span className="lyr-mix-share">{Math.round(rest[i]) + "%"}</span>}
</div>
<div className="lyr-mix-more">
{it.summary}
{it.link && it.linkLabel ? (
<div>
<Link href={it.link}>
<a className="lyr-mix-link">{it.linkLabel}</a>
</Link>
</div>
) : null}
</div>
</div>
)
})}
</div>
)
}
addPropertyControls(MixBand, {
items: {
type: ControlType.Array,
title: "Slices",
maxCount: 12,
control: {
type: ControlType.Object,
controls: {
photo: { type: ControlType.ResponsiveImage, title: "Photo" },
title: { type: ControlType.String, title: "Title", defaultValue: "Slice" },
summary: { type: ControlType.String, title: "Summary", displayTextArea: true, defaultValue: "" },
share: { type: ControlType.Number, title: "Share", min: 1, max: 100, step: 1, defaultValue: 25, description: "This slice's part of the whole. The slices are sized against each other, so they do not have to add up to 100." },
link: { type: ControlType.Link, title: "Link" },
linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "More on this" },
},
},
defaultValue: ITEMS,
},
direction: { type: ControlType.Enum, title: "Direction", options: ["Across", "Down"], optionTitles: ["Across", "Down"], defaultValue: "Across", description: "Across puts the text under each slice. Down lists the text below the band, for phones." },
openTo: { type: ControlType.Number, title: "Open to", min: 20, max: 80, step: 1, unit: "%", defaultValue: 46, description: "How wide a slice grows when you point at it." },
bandHeight: { type: ControlType.Number, title: "Band height", min: 80, max: 800, step: 10, unit: "px", defaultValue: 380 },
labels: { type: ControlType.Enum, title: "Labels", options: ["All", "Open slice only"], optionTitles: ["All", "Open slice only"], defaultValue: "All", description: "With many narrow slices (a team), show the text for the open slice only." },
startsOpen: { type: ControlType.Number, title: "Starts open", min: 0, max: 24, step: 1, displayStepper: true, defaultValue: 0, description: "Which slice is open before anyone points. 0 leaves them all at rest." },
shareLabel: { type: ControlType.Boolean, title: "Share figure", defaultValue: true, enabledTitle: "Show", disabledTitle: "Hide" },
textRoom: { type: ControlType.Number, title: "Text room", min: 60, max: 400, step: 10, unit: "px", defaultValue: 190, description: "Height kept free for the title and summary." },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
scale: { type: ControlType.Number, title: "Scale", min: 0.6, max: 1.4, step: 0.05, defaultValue: 1 },
imageWidth: { type: ControlType.String, title: "Image width", defaultValue: "50vw", description: "How wide the photo is drawn at most, for choosing a file size." },
textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#F2F0EA" },
softColour: { type: ControlType.Color, title: "Soft colour", defaultValue: "#C9C6BE" },
mutedColour: { type: ControlType.Color, title: "Muted colour", defaultValue: "#8B8880" },
lineColour: { type: ControlType.Color, title: "Line colour", defaultValue: "#262623" },
tintColour: { type: ControlType.Color, title: "Tint colour", defaultValue: "#1B1B19" },
pageColour: { type: ControlType.Color, title: "Page colour", defaultValue: "#0A0A09" },
})
In this preview
The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.