Components / Navigation
Playhead
A thin timeline ruler along the navigation's bottom edge: a playhead tracks the scroll and each section gets a marker you can hover and jump to.
Scroll insideFull screen
Props
#FF4A1C
#FF4A1C
#E6E5E1
#E6E5E1
#0E0E0D
#FFFFFF
#0E0E0D
Scroll, then point at a marker on the ruler under the bar.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Playhead shows up in the Insert menu, and its controls appear in the right panel.
// Playhead · Layr Components
//
// An edit-timeline ruler along the bottom edge of the navigation: a thin track
// with ruler ticks, a progress line and a playhead that follow the scroll.
// Every section of the page gets a marker on the ruler; hover one to see its
// name, click it to jump there.
// Paste into a new code file in Framer. Place it along the bottom edge of the
// navigation, full width and 10 px tall. Markers come from the sections of the
// page (layers whose HTML tag is section) and take their layer names.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { motion, useReducedMotion, useScroll, useSpring, useTransform } from "framer-motion"
import { useEffect, useId, useState, type CSSProperties } from "react"
interface PlayheadProps {
trackColor: string
fillColor: string
headColor: string
tickColor: string
showTicks: boolean
tickSpacing: number
showMarkers: boolean
sections: string
markerColor: string
labelColor: string
labelBackground: string
scrollOffset: number
fontFamily: string
style?: CSSProperties
}
interface Marker {
label: string
position: number
top: number
}
const clamp = (value: number) => Math.max(0, Math.min(1, value))
/** A section's name: its marker or layer name, its aria-label, or its first heading. */
function nameOf(section: HTMLElement) {
const own = section.getAttribute("data-marker") || section.getAttribute("data-framer-name") || section.getAttribute("aria-label")
if (own) return own.trim()
return section.querySelector("h1, h2, h3")?.textContent?.trim() ?? ""
}
/** Every outermost section on the page that matches the selector, with its place on the timeline. */
function findMarkers(selector: string): Marker[] {
let found: HTMLElement[] = []
try {
found = Array.from(document.querySelectorAll<HTMLElement>(selector))
} catch (e) {
return []
}
const max = Math.max(1, document.documentElement.scrollHeight - window.innerHeight)
const sections = found.filter((section) => section.offsetHeight > 0 && !section.parentElement?.closest(selector))
return sections.map((section, index) => {
const top = section.getBoundingClientRect().top + window.scrollY
const name = nameOf(section)
const number = String(index + 1).padStart(2, "0")
return { label: name ? `${number} · ${name}` : number, position: clamp(top / max), top }
})
}
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 10
*
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight fixed
*/
export default function Playhead(props: PlayheadProps) {
const {
trackColor = "rgba(242, 239, 233, 0.14)",
fillColor = "#E9FFA1",
headColor = "#E9FFA1",
tickColor = "rgba(242, 239, 233, 0.18)",
showTicks = true,
tickSpacing = 24,
showMarkers = true,
sections = "section[data-framer-name]",
markerColor = "#F2EFE9",
labelColor = "#0A0A0A",
labelBackground = "#F2EFE9",
scrollOffset = 72,
fontFamily = "Inter",
} = props
const isStatic = useIsStaticRenderer()
const reduceMotion = useReducedMotion()
const scope = `lyr-ph-${useId().replace(/[^a-zA-Z0-9]/g, "")}`
const { scrollYProgress } = useScroll()
const progress = useSpring(scrollYProgress, { stiffness: 220, damping: 40, mass: 0.3 })
const position = useTransform(progress, (value) => `${(value * 100).toFixed(3)}%`)
const at = isStatic ? "35%" : position
const [markers, setMarkers] = useState<Marker[]>(() =>
isStatic
? [
{ label: "", position: 0.18, top: 0 },
{ label: "", position: 0.42, top: 0 },
{ label: "", position: 0.66, top: 0 },
{ label: "", position: 0.86, top: 0 },
]
: []
)
// Measure section positions on load and whenever the page changes size — never on scroll.
useEffect(() => {
if (isStatic || !showMarkers) return
let frame = 0
const measure = () => {
frame = 0
const next = findMarkers(sections)
setMarkers((previous) =>
previous.length === next.length &&
previous.every((m, i) => m.label === next[i].label && Math.abs(m.position - next[i].position) < 0.001 && Math.abs(m.top - next[i].top) < 1)
? previous
: next
)
}
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure)
}
measure()
const observer = new ResizeObserver(schedule)
observer.observe(document.body)
window.addEventListener("load", schedule)
return () => {
if (frame) cancelAnimationFrame(frame)
observer.disconnect()
window.removeEventListener("load", schedule)
}
}, [isStatic, showMarkers, sections])
const jump = (marker: Marker) =>
window.scrollTo({ top: Math.max(0, marker.top - scrollOffset), behavior: reduceMotion ? "auto" : "smooth" })
const major = tickSpacing * 5
const font = `"${fontFamily}", "Inter", system-ui, sans-serif`
return (
<div className={scope} style={{ ...props.style, position: "relative", overflow: "visible", pointerEvents: "none" }}>
<style>{`
.${scope} .lyr-ph-marker { position: absolute; bottom: 0; width: 16px; height: 18px; margin-left: -8px; padding: 0; border: 0; background: transparent; cursor: pointer; pointer-events: auto; }
.${scope} .lyr-ph-marker::before { content: ""; position: absolute; left: 7.5px; bottom: 0; width: 1px; height: 9px; background: ${markerColor}; opacity: 0.55; transition: opacity 0.2s, height 0.2s; }
.${scope} .lyr-ph-marker::after { content: ""; position: absolute; left: 7.5px; bottom: 9px; width: 5px; height: 4px; background: ${markerColor}; opacity: 0.55; transition: opacity 0.2s; }
.${scope} .lyr-ph-marker span { position: absolute; bottom: 20px; left: 50%; transform: translate(-50%, 4px); padding: 5px 8px; border-radius: 3px; white-space: nowrap; font: 500 12px/1 ${font}; letter-spacing: -0.005em; color: ${labelColor}; background: ${labelBackground}; opacity: 0; pointer-events: none; transition: opacity 0.2s, transform 0.2s; }
.${scope} .lyr-ph-marker:hover::before, .${scope} .lyr-ph-marker:focus-visible::before { opacity: 1; height: 12px; }
.${scope} .lyr-ph-marker:hover::after, .${scope} .lyr-ph-marker:focus-visible::after { opacity: 1; bottom: 12px; }
.${scope} .lyr-ph-marker:hover span, .${scope} .lyr-ph-marker:focus-visible span { opacity: 1; transform: translate(-50%, 0); }
.${scope} .lyr-ph-marker.first span { left: 0; transform: translate(0, 4px); }
.${scope} .lyr-ph-marker.first:hover span, .${scope} .lyr-ph-marker.first:focus-visible span { transform: translate(0, 0); }
.${scope} .lyr-ph-marker.last span { left: auto; right: 0; transform: translate(0, 4px); }
.${scope} .lyr-ph-marker.last:hover span, .${scope} .lyr-ph-marker.last:focus-visible span { transform: translate(0, 0); }
@media (hover: none) { .${scope} .lyr-ph-marker span { display: none; } }
`}</style>
{showTicks && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 7,
backgroundImage: `repeating-linear-gradient(90deg, ${tickColor} 0 1px, transparent 1px ${major}px), repeating-linear-gradient(90deg, ${tickColor} 0 1px, transparent 1px ${tickSpacing}px)`,
backgroundSize: `100% 7px, 100% 3px`,
backgroundPosition: "left bottom, left bottom",
backgroundRepeat: "no-repeat",
}}
/>
)}
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 1, background: trackColor }} />
<motion.div style={{ position: "absolute", left: 0, bottom: 0, height: 2, width: at, background: fillColor }} />
{showMarkers &&
markers.map((marker, index) => (
<button
key={`${marker.label}-${index}`}
type="button"
className={`lyr-ph-marker${marker.position < 0.12 ? " first" : ""}${marker.position > 0.88 ? " last" : ""}`}
style={{ left: `${(marker.position * 100).toFixed(3)}%` }}
aria-label={marker.label ? `Jump to ${marker.label}` : undefined}
tabIndex={isStatic ? -1 : 0}
onClick={() => !isStatic && jump(marker)}
>
{marker.label && <span>{marker.label}</span>}
</button>
))}
<motion.div
style={{
position: "absolute",
bottom: 0,
left: at,
width: 2,
height: "100%",
marginLeft: -1,
background: headColor,
boxShadow: `0 0 10px ${headColor}`,
}}
>
<div
style={{
position: "absolute",
top: -4,
left: -3,
width: 8,
height: 5,
background: headColor,
clipPath: "polygon(0 0, 100% 0, 50% 100%)",
}}
/>
</motion.div>
</div>
)
}
addPropertyControls(Playhead, {
fillColor: { type: ControlType.Color, title: "Progress", defaultValue: "#E9FFA1" },
headColor: { type: ControlType.Color, title: "Playhead", defaultValue: "#E9FFA1" },
trackColor: { type: ControlType.Color, title: "Track", defaultValue: "rgba(242, 239, 233, 0.14)" },
showTicks: { type: ControlType.Boolean, title: "Ruler", defaultValue: true, enabledTitle: "Show", disabledTitle: "Hide" },
tickColor: { type: ControlType.Color, title: "Ticks", defaultValue: "rgba(242, 239, 233, 0.18)", hidden: (props) => !props.showTicks },
tickSpacing: {
type: ControlType.Number,
title: "Tick Gap",
defaultValue: 24,
min: 6,
max: 120,
unit: "px",
hidden: (props) => !props.showTicks,
},
showMarkers: {
type: ControlType.Boolean,
title: "Markers",
defaultValue: true,
enabledTitle: "Show",
disabledTitle: "Hide",
description: "One marker per section. Markers use the section layer names.",
},
sections: {
type: ControlType.String,
title: "Sections",
defaultValue: "section[data-framer-name]",
hidden: (props) => !props.showMarkers,
description: "Which layers get a marker, as a CSS selector. The default is every named layer whose HTML tag is section.",
},
markerColor: { type: ControlType.Color, title: "Marker", defaultValue: "#F2EFE9", hidden: (props) => !props.showMarkers },
labelColor: { type: ControlType.Color, title: "Label Text", defaultValue: "#0A0A0A", hidden: (props) => !props.showMarkers },
labelBackground: { type: ControlType.Color, title: "Label Fill", defaultValue: "#F2EFE9", hidden: (props) => !props.showMarkers },
scrollOffset: {
type: ControlType.Number,
title: "Jump Offset",
defaultValue: 72,
min: 0,
max: 300,
unit: "px",
hidden: (props) => !props.showMarkers,
description: "Space left above a section after jumping to it (the navigation height).",
},
fontFamily: {
type: ControlType.String,
title: "Font family",
defaultValue: "Inter",
hidden: (props) => !props.showMarkers,
description: "For the marker names. Use the family name of your text styles.",
},
})
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.