Components / Hero
Corner Card
A tall portrait on the first screen shrinks into the corner as you scroll and stays there as a small card with a name, status and call button.
Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
#F5F4F1
#E6E5E1
#FF4A1C
#FFFFFF
Set in code: Photo. The preview fills these in with demo content.
Scroll inside the preview.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Corner Card shows up in the Insert menu, and its controls appear in the right panel.
// Corner Card · Layr Components
//
// Your portrait in the bottom right corner of the window. With Starts large on it
// opens as a tall portrait that fills that corner of the first screen, shrinks into
// the corner as the page scrolls, and stays there as a small card with your name,
// a status line and a call button. It steps aside while the section named in
// Hide at (your closing call section, say) is in view.
// Paste into a new code file in Framer. Put one in the layout template, anywhere:
// it fixes itself to the corner of the window. Turn Starts large on for the page
// whose first screen leaves room for the portrait. Without a photo the portrait
// shows a soft tone.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"
type Img = { src: string; srcSet?: string; alt?: string }
interface CornerCardProps {
photo?: Img
altText: string
name: string
caption: string
status: string
callLabel: string
shortLabel: string
callLink: string
startsLarge: boolean
wide: number
spaceAbove: number
spaceAbovePhone: number
focus: string
hideAt: string
fontFamily: string
textColour: string
softColour: string
cardColour: string
lineColour: string
accentColour: string
onAccentColour: string
style?: CSSProperties
}
const SHEET = `
.lyr-cc{position:fixed;right:0;bottom:0;width:0;height:0;z-index:8;--lyr-cc-t:1;--lyr-cc-lift:0px;--lyr-cc-w0:min(var(--lyr-cc-wide),620px);--lyr-cc-h0:calc(100vh - var(--lyr-cc-top));color:var(--lyr-cc-ink);-webkit-font-smoothing:antialiased;pointer-events:none;transition:opacity .3s ease}
@supports (height:1svh){.lyr-cc{--lyr-cc-h0:calc(100svh - var(--lyr-cc-top))}}
.lyr-cc-ph{position:fixed;right:calc(34px * var(--lyr-cc-t));bottom:calc(35px * var(--lyr-cc-t) + var(--lyr-cc-lift) * var(--lyr-cc-t));width:calc(var(--lyr-cc-w0) * (1 - var(--lyr-cc-t)) + 60px * var(--lyr-cc-t));height:calc(var(--lyr-cc-h0) * (1 - var(--lyr-cc-t)) + 60px * var(--lyr-cc-t));border-radius:calc(6px * var(--lyr-cc-t));overflow:hidden;background:var(--lyr-cc-card);z-index:2;will-change:width,height}
.lyr-cc-ph img,.lyr-cc-empty{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:var(--lyr-cc-pos);display:block}
.lyr-cc-empty{background:radial-gradient(38% 30% at 50% 34%,rgba(255,255,255,.12),rgba(255,255,255,0) 72%),linear-gradient(170deg,rgba(255,255,255,.06),rgba(0,0,0,.2))}
.lyr-cc-cap{position:absolute;left:22px;right:22px;bottom:20px;color:#fff;font-size:15px;line-height:1.35;letter-spacing:-.01em;opacity:calc(1 - var(--lyr-cc-t) * 4);text-shadow:0 1px 18px rgba(0,0,0,.35)}
.lyr-cc-cap span{display:block;font-size:13.5px;opacity:.82}
.lyr-cc-card{position:fixed;right:24px;bottom:calc(24px + var(--lyr-cc-lift));height:82px;display:flex;align-items:center;gap:16px;padding:0 84px 0 18px;background:var(--lyr-cc-card);border:1px solid var(--lyr-cc-line);border-radius:10px;white-space:nowrap;opacity:0;transform:translate3d(0,8px,0);transition:opacity .25s ease,transform .35s cubic-bezier(.2,.7,.2,1);z-index:1}
.lyr-cc-still .lyr-cc-card,.lyr-cc[data-docked] .lyr-cc-card{opacity:1;transform:none;pointer-events:auto}
.lyr-cc-name{font-size:15px;line-height:1.3;font-weight:500;letter-spacing:-.01em}
.lyr-cc-status{display:flex;align-items:center;gap:7px;margin-top:2px;font-size:13.5px;line-height:1.3;color:var(--lyr-cc-soft)}
.lyr-cc-dot{width:7px;height:7px;border-radius:50%;background:var(--lyr-cc-accent);flex:none}
.lyr-cc-btn{display:inline-flex;align-items:center;height:40px;padding:0 15px;border-radius:6px;background:var(--lyr-cc-accent);color:var(--lyr-cc-onaccent);font-size:15px;font-weight:500;letter-spacing:-.01em;text-decoration:none;transition:opacity .2s ease}
.lyr-cc-short{display:none}
.lyr-cc[data-away]{opacity:0}
.lyr-cc[data-away] .lyr-cc-card{pointer-events:none}
@media (hover:hover){.lyr-cc-btn:hover{opacity:.86}}
@media (max-width:809px){
.lyr-cc{--lyr-cc-w0:100vw;--lyr-cc-h0:calc(100vh - var(--lyr-cc-top-phone))}
.lyr-cc-ph{right:calc(22px * var(--lyr-cc-t));bottom:calc(22px * var(--lyr-cc-t) + var(--lyr-cc-lift) * var(--lyr-cc-t));width:calc(var(--lyr-cc-w0) * (1 - var(--lyr-cc-t)) + 44px * var(--lyr-cc-t));height:calc(var(--lyr-cc-h0) * (1 - var(--lyr-cc-t)) + 44px * var(--lyr-cc-t))}
.lyr-cc-card{right:14px;bottom:calc(14px + var(--lyr-cc-lift));height:60px;padding:0 60px 0 8px;gap:0}
.lyr-cc-text{display:none}
.lyr-cc-long{display:none}
.lyr-cc-short{display:inline}
}
@supports (height:1svh){@media (max-width:809px){.lyr-cc{--lyr-cc-h0:calc(100svh - var(--lyr-cc-top-phone))}}}
@media (prefers-reduced-motion:reduce){.lyr-cc,.lyr-cc-card{transition:none}}
`
const clamp = (v: number) => Math.min(1, Math.max(0, v))
/**
* @framerIntrinsicWidth 240
* @framerIntrinsicHeight 82
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight fixed
*/
export default function CornerCard(props: CornerCardProps) {
const {
photo,
altText = "",
name = "Jordan Hale",
caption = "In practice since 2011",
status = "First call free",
callLabel = "Call 555 0142",
shortLabel = "Call",
callLink = "tel:+15555550142",
startsLarge = false,
wide = 39,
spaceAbove = 96,
spaceAbovePhone = 420,
focus = "50% 20%",
hideAt = "contact",
fontFamily = "Inter",
textColour = "#F2EFE2",
softColour = "#C8D2C6",
cardColour = "#0F2F2B",
lineColour = "#1B3F3A",
accentColour = "#FFE09E",
onAccentColour = "#071C1A",
style,
} = props
const isStatic = useIsStaticRenderer()
const root = useRef<HTMLDivElement>(null)
const large = !!startsLarge && !isStatic
useEffect(() => {
const el = root.current
if (!el || isStatic) return
const reduced = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches)
let goal = large ? 0 : 1,
shown = goal,
raf = 0,
last = 0
// Framer's "Made in Framer" badge sits in the same corner: lift the card above it.
const badge = () => {
const b = document.getElementById("__framer-badge-container")
const r = b ? b.getBoundingClientRect() : null
el.style.setProperty("--lyr-cc-lift", r && r.height > 4 && r.bottom > window.innerHeight - 90 ? `${Math.round(window.innerHeight - r.top + 6 - 24)}px` : "0px")
}
// Hide at takes one id or several, separated by commas. Elements carrying data-lyr-corner-stop count too.
const ids = String(hideAt || "")
.split(",")
.map((s) => s.replace(/^#/, "").trim())
.filter(Boolean)
const read = () => {
goal = large ? clamp(window.scrollY / Math.max(240, window.innerHeight * 0.8)) : 1
const stops: Element[] = Array.from(document.querySelectorAll("[data-lyr-corner-stop]"))
for (const id of ids) {
const named = document.getElementById(id)
if (named) stops.push(named)
}
const now = stops.some((s) => {
const r = s.getBoundingClientRect()
return r.width > 2 && r.height > 2 && r.top < window.innerHeight - 140
})
el.toggleAttribute("data-away", now)
}
const paint = () => {
const e = shown < 0.5 ? 2 * shown * shown : 1 - Math.pow(-2 * shown + 2, 2) / 2
el.style.setProperty("--lyr-cc-t", String(Math.round(e * 10000) / 10000))
el.toggleAttribute("data-docked", shown > 0.985)
}
const tick = (t: number) => {
const dt = last ? Math.min(64, t - last) : 16
last = t
if (reduced) shown = goal
else shown += (goal - shown) * (1 - Math.exp(-dt / 90))
if (Math.abs(goal - shown) < 0.0008) shown = goal
paint()
raf = shown === goal ? 0 : requestAnimationFrame(tick)
if (!raf) last = 0
}
const wake = () => {
read()
if (!raf) raf = requestAnimationFrame(tick)
}
read()
shown = goal
paint()
badge()
const later = window.setTimeout(badge, 1500)
const onResize = () => {
badge()
wake()
}
window.addEventListener("scroll", wake, { passive: true })
window.addEventListener("resize", onResize)
return () => {
window.removeEventListener("scroll", wake)
window.removeEventListener("resize", onResize)
window.clearTimeout(later)
if (raf) cancelAnimationFrame(raf)
}
}, [isStatic, large, hideAt])
const family = `"${String(fontFamily || "Inter").replace(/['"]/g, "")}", "Inter", system-ui, sans-serif`
const vars = {
"--lyr-cc-t": large ? 0 : 1,
"--lyr-cc-wide": `${Math.min(60, Math.max(20, Number(wide) || 39))}vw`,
"--lyr-cc-top": `${Math.max(0, Number(spaceAbove) || 0)}px`,
"--lyr-cc-top-phone": `${Math.max(0, Number(spaceAbovePhone) || 420)}px`,
"--lyr-cc-pos": String(focus || "50% 20%"),
"--lyr-cc-ink": textColour,
"--lyr-cc-soft": softColour,
"--lyr-cc-card": cardColour,
"--lyr-cc-line": lineColour,
"--lyr-cc-accent": accentColour,
"--lyr-cc-onaccent": onAccentColour,
} as CSSProperties
if (isStatic) {
// On the canvas: a small tile, so the layer can be found and selected without covering the page.
return (
<div style={{ ...style, width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: family, fontSize: 13, color: textColour, background: cardColour, border: `1px solid ${lineColour}`, borderRadius: 10 }}>
Corner card
</div>
)
}
const img = photo && photo.src ? photo : null
const href = typeof callLink === "string" ? callLink : ""
return (
<div ref={root} className={"lyr-cc" + (large ? "" : " lyr-cc-still")} style={{ ...vars, fontFamily: family }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div className="lyr-cc-ph">
{img ? (
<img loading={large ? "eager" : "lazy"} decoding="async" srcSet={img.srcSet} sizes="(max-width: 809px) 100vw, 40vw" src={img.src} alt={String(altText || img.alt || name || "")} />
) : (
<i className="lyr-cc-empty" role="img" aria-label={String(altText || name || "")} />
)}
{large && (name || caption) ? (
<div className="lyr-cc-cap">
{name}
{caption ? <span>{caption}</span> : null}
</div>
) : null}
</div>
<div className="lyr-cc-card">
<div className="lyr-cc-text">
<div className="lyr-cc-name">{name}</div>
{status ? (
<div className="lyr-cc-status">
<span className="lyr-cc-dot" />
{status}
</div>
) : null}
</div>
{href ? (
<a className="lyr-cc-btn" href={href}>
<span className="lyr-cc-long">{callLabel}</span>
<span className="lyr-cc-short">{shortLabel || callLabel}</span>
</a>
) : null}
</div>
</div>
)
}
addPropertyControls(CornerCard, {
photo: { type: ControlType.ResponsiveImage, title: "Photo" },
altText: { type: ControlType.String, title: "Alt text", defaultValue: "", description: "Describes the photo for screen readers." },
name: { type: ControlType.String, title: "Name", defaultValue: "Jordan Hale" },
caption: { type: ControlType.String, title: "Caption", defaultValue: "In practice since 2011", description: "A line under the name while the portrait is large." },
status: { type: ControlType.String, title: "Status", defaultValue: "First call free", description: "The small line on the card: First call free." },
callLabel: { type: ControlType.String, title: "Button label", defaultValue: "Call 555 0142" },
shortLabel: { type: ControlType.String, title: "Phone label", defaultValue: "Call", description: "The shorter button label used on phones." },
callLink: { type: ControlType.String, title: "Button link", defaultValue: "tel:+15555550142", description: "Where the button goes: tel:+15555550142, a page or a mail address." },
startsLarge: { type: ControlType.Boolean, title: "Starts large", defaultValue: false, description: "On for a page whose first screen leaves room for the portrait." },
wide: { type: ControlType.Number, title: "Portrait width", min: 20, max: 60, step: 1, unit: "vw", defaultValue: 39, description: "Width of the large portrait on wide screens." },
spaceAbove: { type: ControlType.Number, title: "Space above", min: 0, max: 400, step: 4, unit: "px", defaultValue: 96, description: "Gap between the top of the window and the large portrait." },
spaceAbovePhone: { type: ControlType.Number, title: "Space above on phones", min: 100, max: 700, step: 10, unit: "px", defaultValue: 420, description: "Room kept for the text above the portrait on phones." },
focus: { type: ControlType.String, title: "Focus", defaultValue: "50% 20%", description: "Where the face is in the photo, across then down: 50% 20%." },
hideAt: { type: ControlType.String, title: "Hide at", defaultValue: "contact", description: "The id of a section where the card steps aside, such as the closing call section. Several ids can be separated by commas." },
fontFamily: { type: ControlType.String, title: "Font", defaultValue: "Inter", description: "The family name used by the text styles." },
textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#F2EFE2" },
softColour: { type: ControlType.Color, title: "Status colour", defaultValue: "#C8D2C6" },
cardColour: { type: ControlType.Color, title: "Card colour", defaultValue: "#0F2F2B" },
lineColour: { type: ControlType.Color, title: "Line colour", defaultValue: "#1B3F3A" },
accentColour: { type: ControlType.Color, title: "Button colour", defaultValue: "#FFE09E" },
onAccentColour: { type: ControlType.Color, title: "Button text colour", defaultValue: "#071C1A" },
})
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.