Layr Components

Components / Text

Credits Roll

End credits, role over name, rolling slowly upwards in a softly faded window like the end of a film; the list is yours to edit.

Props
28 px/s
28 px
13 px
28 px
#6E6E6B
#0E0E0D

Set in code: Credits. The preview fills these in with demo content.

The credits roll on their own; they stand still with reduced motion.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Credits Roll shows up in the Insert menu, and its controls appear in the right panel.
// Credits Roll · Layr Components
//
// End credits, role over name, rolling slowly upwards in a window that fades
// at the top and bottom, like the end of a film. The list loops without a seam,
// only moves while it is on screen, and stands still for visitors who prefer
// reduced motion.
// Paste into a new code file in Framer. Give it a fixed height; the credits
// roll inside it. Edit the list in the Credits control.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useReducedMotion } from "framer-motion"
import { useEffect, useId, useRef, type CSSProperties } from "react"

interface Credit {
    role?: string
    name?: string
}

interface CreditsRollProps {
    items: Credit[]
    speed: number
    gap: number
    fontFamily: string
    roleSize: number
    nameSize: number
    roleColor: string
    nameColor: string
    style?: CSSProperties
}

const ITEMS: Credit[] = [
    { role: "Directed by", name: "Mara Ellis" },
    { role: "Director of photography", name: "Tom Reyes" },
    { role: "Edit and colour", name: "June Park" },
    { role: "Produced by", name: "Sam Adler" },
    { role: "Sound", name: "Leo Grant" },
    { role: "Filmed in", name: "Lisbon and Porto" },
    { role: "Thank you for", name: "Watching" },
]

/**
 * @framerIntrinsicWidth 480
 * @framerIntrinsicHeight 260
 *
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight fixed
 */
export default function CreditsRoll(props: CreditsRollProps) {
    const {
        items = ITEMS,
        speed = 28,
        gap = 28,
        fontFamily = "Inter",
        roleSize = 13,
        nameSize = 28,
        roleColor = "#8F8C86",
        nameColor = "#F2EFE9",
    } = props
    const isStatic = useIsStaticRenderer()
    const reduceMotion = useReducedMotion()
    const still = isStatic || !!reduceMotion
    const scope = `lyr-cr-${useId().replace(/[^a-zA-Z0-9]/g, "")}`
    const rootRef = useRef<HTMLDivElement>(null)
    const trackRef = useRef<HTMLDivElement>(null)

    useEffect(() => {
        const root = rootRef.current
        const track = trackRef.current
        if (still || !root || !track) return
        // The track holds the list twice; one loop scrolls exactly one copy.
        const measure = () => {
            const distance = track.scrollHeight / 2
            track.style.setProperty("--distance", `-${distance}px`)
            track.style.animationDuration = `${Math.max(4, distance / Math.max(1, speed))}s`
        }
        measure()
        const resize = new ResizeObserver(measure)
        resize.observe(track)
        // Only roll while on screen.
        const view = new IntersectionObserver(([entry]) => {
            track.style.animationPlayState = entry.isIntersecting ? "running" : "paused"
        })
        view.observe(root)
        return () => {
            resize.disconnect()
            view.disconnect()
        }
    }, [still, speed, items.length])

    const list = (copy: number) =>
        items.map((credit, index) => (
            <div key={`${copy}-${index}`} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, paddingBottom: gap }}>
                <span style={{ color: roleColor, fontSize: roleSize, lineHeight: 1.2, letterSpacing: "-0.005em" }}>{credit.role}</span>
                <span style={{ color: nameColor, fontSize: nameSize, lineHeight: 1.1, letterSpacing: "-0.02em" }}>{credit.name}</span>
            </div>
        ))

    return (
        <div
            ref={rootRef}
            className={scope}
            style={{
                ...props.style,
                position: "relative",
                overflow: "hidden",
                textAlign: "center",
                fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
                maskImage: "linear-gradient(180deg, transparent 0%, #000 22%, #000 78%, transparent 100%)",
                WebkitMaskImage: "linear-gradient(180deg, transparent 0%, #000 22%, #000 78%, transparent 100%)",
            }}
        >
            <style>{`@keyframes ${scope}-roll { from { transform: translateY(0); } to { transform: translateY(var(--distance, -50%)); } }`}</style>
            <div
                ref={trackRef}
                role="group"
                aria-label="Credits"
                style={{
                    paddingTop: still ? 24 : 0,
                    animation: still ? undefined : `${scope}-roll 20s linear infinite`,
                    willChange: still ? undefined : "transform",
                }}
            >
                {list(0)}
                {!still && <div aria-hidden="true">{list(1)}</div>}
            </div>
        </div>
    )
}

addPropertyControls(CreditsRoll, {
    items: {
        type: ControlType.Array,
        title: "Credits",
        control: {
            type: ControlType.Object,
            controls: {
                role: { type: ControlType.String, title: "Role", defaultValue: "Role" },
                name: { type: ControlType.String, title: "Name", defaultValue: "Name" },
            },
        },
        defaultValue: ITEMS,
    },
    speed: { type: ControlType.Number, title: "Speed", defaultValue: 28, min: 4, max: 200, unit: "px/s" },
    gap: { type: ControlType.Number, title: "Gap", defaultValue: 28, min: 0, max: 120, unit: "px" },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
    roleSize: { type: ControlType.Number, title: "Role size", defaultValue: 13, min: 9, max: 32, step: 1, unit: "px" },
    nameSize: { type: ControlType.Number, title: "Name size", defaultValue: 28, min: 12, max: 96, step: 1, unit: "px" },
    roleColor: { type: ControlType.Color, title: "Role Color", defaultValue: "#8F8C86" },
    nameColor: { type: ControlType.Color, title: "Name Color", defaultValue: "#F2EFE9" },
})

In this preview

No pictures needed.