Layr Components

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
46%
380 px
0
190 px
1
#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.