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
#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.