macbookScroll
A Scroll block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call macbookScroll() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full behavioral structure is implemented: position pinned scroll range, two sequential rAF-lerped phases (lid rotateX from -90deg to -8deg, then screen image scale 1x to 1.55x bleeding past the bezel via overflow), a literal 6-row QWERTY keyboard built from ~70 individual key elements plus a trackpad and a customizable bottom-left sticker/badge, all above a heading. Marked 'partial' purely on VISUAL fidelity, not behavior: the shell is built from plain rounded divs/theme-color fills rather than a hand-drawn/illustrated vector shell with the metallic highlight shading the reference has, and the exact rotation/scale numeric ranges are approximated per the spec's own researchNote ('low confidence on precise degree/scale values, implementer should tune to taste') -- there was no real value to diff against. Keycap/trackpad/sticker colors are intentionally fixed device-material shades that don't track the host page's ambient theme (documented via _doctorDisable on tone-background-inherit, same precedent as this package's lampEffect.ts glow elements).
Status: partial · Reference: Aceternity UI original
// Aceternity UI "Macbook Scroll" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// laptop shell built entirely from Domphy elements (lid/bezel, keyboard,
// trackpad, sticker) whose screen shows a supplied image; the lid opens and
// the screen image scales up past its own bezel as the page scrolls through
// a tall wrapper, simulating the screen content "bursting free" of the
// device.
//
// Same `position: sticky` pinned-range idiom `textReveal()` uses: a tall
// outer wrapper defines the scroll room, an inner `position: sticky` stage
// stays pinned to the viewport for that whole range, and scroll progress
// (0 at pin-start, 1 at pin-release) is computed from the OUTER wrapper's
// `getBoundingClientRect()` against `window.innerHeight`. That single 0–1
// value is split into two sequential (non-overlapping) phases exactly per
// spec: the first half rotates the lid from a reclined/closed angle to a
// slightly-reclined "open" angle (`rotateX`, hinged at the lid's bottom
// edge via `transform-origin`), the second half scales the screen image up
// from 1x past the bezel's own edges (the bezel/lid stay `overflow:
// visible` so the enlarged image can visibly bleed out over the frame).
// Both phases are rAF-lerped toward their raw scroll target (the same
// smoothing idiom `scrollProgress`/`textReveal` use) and written directly
// to the DOM refs captured in `_onMount`, not routed through reactive
// `State` — this runs every scroll frame and the shell has dozens of static
// child nodes (keyboard keys), so a single imperative paint is cheaper than
// re-running many reactive style functions per tick.
//
// The keyboard and trackpad are NOT decorative flourishes — every key is
// its own small static element in a literal row layout (function row,
// number row, three letter rows, bottom modifier row), matching the
// reference's "authentic detail via many small DOM nodes" technique. Keys
// do not animate individually.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { heading, small, strong } from "@domphy/ui";
import { themeColor, themeSpacing } from "@domphy/theme";
export interface MacbookScrollProps {
/** Screen content image. Defaults to a generated placeholder screenshot. */
image?: string;
/** Accessible label for the screen image. Defaults to `"App screen preview"`. */
imageAlt?: string;
/** Heading rendered above the device. Defaults to a short demo line. Pass `null` to omit it. */
title?: string | DomphyElement | null;
/** Sticker rendered near the base's bottom-left corner. Defaults to a small "D" logo mark. Pass `null` to omit it. */
badge?: DomphyElement | null;
/** Toggles a soft radial gradient backdrop behind the whole scene. Defaults to `true`. */
showGradient?: boolean;
/** How tall the scroll wrapper is, in viewport-height units — more height means a slower-feeling
* open/scale sequence for the same scroll distance. Defaults to `280`, clamped to a minimum of `160`. */
wrapperHeightVh?: number;
/** Passthrough style merged onto the outer scroll wrapper. */
style?: StyleObject;
}
// Lid hinge angle, in degrees, at scroll progress 0 (closed/reclined, mostly
// hidden from the viewer) and at the end of phase 1 (open, angled slightly
// back like a laptop in normal use — matching real hinge travel rather than
// a full 90°).
const LID_CLOSED_ROTATE_X_DEGREES = -90;
const LID_OPEN_ROTATE_X_DEGREES = -8;
// Screen-image scale at the start and end of phase 2.
const SCREEN_SCALE_MIN = 1;
const SCREEN_SCALE_MAX = 1.55;
// Fraction of the overall 0–1 scroll progress where phase 1 (lid opening) ends
// and phase 2 (screen scaling) begins.
const PHASE_SPLIT = 0.5;
interface KeyboardKey {
label: string;
grow: number;
}
// Row-by-row QWERTY layout. `grow` is a relative flex-grow weight — wider
// keys (space, shift, tab, return, modifiers) get a larger share of the row.
const KEYBOARD_ROWS: KeyboardKey[][] = [
["esc", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"].map((label) => ({ label, grow: 1 })),
["`", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=", "delete"].map((label) => ({
label,
grow: label === "delete" ? 1.6 : 1,
})),
["tab", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "[", "]", "\\"].map((label) => ({
label,
grow: label === "tab" ? 1.5 : 1,
})),
["caps", "A", "S", "D", "F", "G", "H", "J", "K", "L", ";", "'", "return"].map((label) => ({
label,
grow: label === "caps" || label === "return" ? 1.7 : 1,
})),
["shift", "Z", "X", "C", "V", "B", "N", "M", ",", ".", "/", "shift"].map((label) => ({
label,
grow: label === "shift" ? 2.1 : 1,
})),
[
{ label: "fn", grow: 1 },
{ label: "control", grow: 1.3 },
{ label: "option", grow: 1.3 },
{ label: "command", grow: 1.6 },
{ label: "", grow: 6 },
{ label: "command", grow: 1.6 },
{ label: "option", grow: 1.3 },
{ label: "◀", grow: 1 },
{ label: "▲▼", grow: 1 },
{ label: "▶", grow: 1 },
],
];
function clampToUnitRange(value: number): number {
if (Number.isNaN(value)) return 0;
return Math.min(1, Math.max(0, value));
}
/** Pinned-range progress: 0 when the wrapper's top reaches the viewport top
* (the sticky stage begins pinning), 1 when its bottom reaches the viewport
* bottom (the stage is about to release) — same math `textReveal()` uses. */
function computePinnedProgress(wrapperElement: HTMLElement): number {
const rect = wrapperElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const scrollableDistance = rect.height - viewportHeight;
const raw = scrollableDistance > 0 ? -rect.top / scrollableDistance : rect.top <= 0 ? 1 : 0;
return clampToUnitRange(raw);
}
function keyElement(key: KeyboardKey, rowIndex: number, keyIndex: number): DomphyElement<"div"> {
// `_doctorDisable` is a doctor-only annotation not present in core's strict
// `PartialElement` type — build through an untyped literal, then assert,
// so the excess-property check the function's declared return type would
// otherwise apply doesn't fire (mirrors `scrollProgress.ts`/`warpBackground.ts`).
return {
div: key.label ? [{ small: key.label, $: [small({ color: "neutral" })] } as DomphyElement] : [],
_key: `macbook-key-${rowIndex}-${keyIndex}`,
// A keycap is a fixed device-material color, not a surface that should
// track the host page's ambient dataTone context — a dark-mode toggle on
// the surrounding page shouldn't turn this illustrated MacBook's keys
// black. Same reasoning as `lampEffect.ts`'s glow blobs/bar.
_doctorDisable: "tone-background-inherit",
style: {
flexGrow: key.grow,
flexBasis: 0,
minWidth: 0,
height: themeSpacing(6),
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: themeSpacing(1),
backgroundColor: (listener: Listener) => themeColor(listener, "shift-4"),
color: (listener: Listener) => themeColor(listener, "shift-13"),
} as StyleObject,
} as DomphyElement<"div">;
}
function keyboardRow(row: KeyboardKey[], rowIndex: number): DomphyElement<"div"> {
return {
div: row.map((key, keyIndex) => keyElement(key, rowIndex, keyIndex)),
_key: `macbook-row-${rowIndex}`,
style: { display: "flex", gap: themeSpacing(0.5) } as StyleObject,
};
}
function defaultBadge(): DomphyElement<"div"> {
// `_doctorDisable` is a doctor-only annotation not present in core's strict
// `PartialElement` type — build through an untyped literal, then assert
// (mirrors `keyElement` above).
return {
div: [{ strong: "D", $: [strong({ color: "neutral" })] } as DomphyElement],
ariaHidden: "true",
// A brand sticker's color is a fixed accent, not a surface that should
// track the ambient page dataTone context. Same reasoning as
// `lampEffect.ts`'s glow blobs/bar.
_doctorDisable: "tone-background-inherit",
style: {
position: "absolute",
insetBlockEnd: themeSpacing(3),
insetInlineStart: themeSpacing(4),
width: themeSpacing(6),
height: themeSpacing(6),
borderRadius: themeSpacing(1.5),
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: (listener: Listener) => themeColor(listener, "shift-9", "primary"),
color: (listener: Listener) => themeColor(listener, "shift-0", "primary"),
} as StyleObject,
} as DomphyElement<"div">;
}
interface MacbookRuntime {
lidElement: HTMLElement | null;
screenElement: HTMLElement | null;
chromeElement: HTMLElement | null;
}
/**
* A scroll-driven MacBook shell (lid, keyboard, trackpad, sticker) whose
* screen image opens and scales up past the bezel as the page scrolls
* through a tall wrapper — purely scroll-driven, no click required. Call
* with no arguments for a working demo (placeholder screenshot, default
* heading and sticker).
*/
function macbookScroll(props: MacbookScrollProps = {}): DomphyElement<"div"> {
const image = props.image ?? "https://picsum.photos/seed/domphy-macbook-scroll/1200/750";
const imageAlt = props.imageAlt ?? "App screen preview";
const showGradient = props.showGradient ?? true;
const wrapperHeightVh = Math.max(160, Math.round(props.wrapperHeightVh ?? 280));
const titleNode: DomphyElement | null =
props.title === null
? null
: props.title && typeof props.title !== "string"
? props.title
: ({ h2: props.title ?? "Scroll to open. Scroll to zoom.", $: [heading()] } as DomphyElement);
const badgeNode = props.badge === null ? null : (props.badge ?? defaultBadge());
const runtime: MacbookRuntime = { lidElement: null, screenElement: null, chromeElement: null };
function paint(progress: number): void {
const rotationProgress = clampToUnitRange(progress / PHASE_SPLIT);
const scaleProgress = clampToUnitRange((progress - PHASE_SPLIT) / (1 - PHASE_SPLIT));
const rotateXDegrees = LID_CLOSED_ROTATE_X_DEGREES + (LID_OPEN_ROTATE_X_DEGREES - LID_CLOSED_ROTATE_X_DEGREES) * rotationProgress;
if (runtime.lidElement) runtime.lidElement.style.transform = `rotateX(${rotateXDegrees.toFixed(2)}deg)`;
const screenScale = SCREEN_SCALE_MIN + (SCREEN_SCALE_MAX - SCREEN_SCALE_MIN) * scaleProgress;
if (runtime.screenElement) runtime.screenElement.style.transform = `scale(${screenScale.toFixed(3)})`;
if (runtime.chromeElement) {
const chromeLift = -6 * scaleProgress;
runtime.chromeElement.style.transform = `translateY(${chromeLift.toFixed(1)}px)`;
runtime.chromeElement.style.opacity = `${(1 - 0.12 * scaleProgress).toFixed(3)}`;
}
}
const bezel: DomphyElement<"div"> = {
div: [
{
span: null,
ariaHidden: "true",
// A purely decorative camera-notch pill with no text of its own —
// exempt from the missing-color contract (same idiom as this
// package's other bare decorative shapes, e.g. `warpBackground`'s
// grid lines). Its fill is also a fixed device-material shade, not a
// surface that should track the ambient page dataTone context (same
// reasoning as `lampEffect.ts`'s glow blobs/bar).
_doctorDisable: ["missing-color", "tone-background-inherit"],
style: {
position: "absolute",
insetBlockStart: themeSpacing(1),
insetInlineStart: "50%",
transform: "translateX(-50%)",
width: themeSpacing(6),
height: themeSpacing(1.2),
borderRadius: themeSpacing(999),
backgroundColor: (listener: Listener) => themeColor(listener, "shift-13"),
} as StyleObject,
} as DomphyElement,
{
img: null,
src: image,
alt: imageAlt,
_doctorDisable: "missing-color",
_onMount: (node: ElementNode) => {
runtime.screenElement = node.domElement as HTMLElement;
},
_onRemove: () => {
runtime.screenElement = null;
},
style: {
position: "absolute",
inset: "4.5%",
width: "91%",
height: "91%",
objectFit: "cover",
borderRadius: themeSpacing(1),
transformOrigin: "50% 50%",
zIndex: 2,
} as StyleObject,
} as DomphyElement,
],
ariaHidden: "true",
dataTone: "shift-17",
style: {
position: "absolute",
inset: 0,
overflow: "visible",
borderRadius: `${themeSpacing(3)} ${themeSpacing(3)} 0 0`,
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
} as StyleObject,
};
const lid: DomphyElement<"div"> = {
div: [bezel],
ariaHidden: "true",
_onMount: (node: ElementNode) => {
runtime.lidElement = node.domElement as HTMLElement;
},
_onRemove: () => {
runtime.lidElement = null;
},
style: {
position: "relative",
width: "100%",
aspectRatio: "16 / 10",
transformOrigin: "50% 100%",
transform: `rotateX(${LID_CLOSED_ROTATE_X_DEGREES}deg)`,
transformStyle: "preserve-3d",
} as StyleObject,
};
const keyboardArea: DomphyElement<"div"> = {
div: KEYBOARD_ROWS.map((row, index) => keyboardRow(row, index)),
ariaHidden: "true",
style: { display: "flex", flexDirection: "column", gap: themeSpacing(0.5), width: "94%" } as StyleObject,
};
// `_doctorDisable` is a doctor-only annotation not present in core's strict
// `PartialElement` type — build through an untyped literal, then assert
// (mirrors `keyElement` above).
const trackpad = {
div: null,
ariaHidden: "true",
// The trackpad's glass color is a fixed device-material shade, not a
// surface that should track the ambient page dataTone context. Same
// reasoning as `lampEffect.ts`'s glow blobs/bar.
_doctorDisable: "tone-background-inherit",
style: {
width: "36%",
height: themeSpacing(14),
borderRadius: themeSpacing(2),
backgroundColor: (listener: Listener) => themeColor(listener, "shift-3"),
color: (listener: Listener) => themeColor(listener, "shift-13"),
boxShadow: (listener: Listener) => `inset 0 0 0 1px ${themeColor(listener, "shift-6")}`,
} as StyleObject,
} as DomphyElement<"div">;
const base: DomphyElement<"div"> = {
div: [
keyboardArea,
trackpad,
...(badgeNode ? [badgeNode] : []),
],
ariaHidden: "true",
dataTone: "shift-2",
_onMount: (node: ElementNode) => {
runtime.chromeElement = node.domElement as HTMLElement;
},
_onRemove: () => {
runtime.chromeElement = null;
},
style: {
position: "relative",
width: "100%",
aspectRatio: "16 / 9.4",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: themeSpacing(3),
paddingBlockStart: themeSpacing(3),
borderRadius: `0 0 ${themeSpacing(4)} ${themeSpacing(4)}`,
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-13"),
boxShadow: (listener: Listener) => `0 ${themeSpacing(3)} ${themeSpacing(10)} ${themeColor(listener, "shift-17")}`,
transition: "transform 200ms ease, opacity 200ms ease",
} as StyleObject,
};
const laptop: DomphyElement<"div"> = {
div: [lid, base],
style: {
position: "relative",
width: "min(92vw, 60em)",
marginInline: "auto",
} as StyleObject,
};
const gradientBackdrop: DomphyElement<"div"> | null = showGradient
? ({
div: null,
ariaHidden: "true",
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: 0,
pointerEvents: "none",
backgroundImage: (listener: Listener) =>
`radial-gradient(ellipse at 50% 30%, ${themeColor(listener, "shift-8", "primary")}, transparent 60%)`,
opacity: 0.35,
} as StyleObject,
} as DomphyElement<"div">)
: null;
return {
div: [
{
div: [
...(gradientBackdrop ? [gradientBackdrop] : []),
...(titleNode
? [
{
div: [titleNode],
style: { position: "relative", zIndex: 1, textAlign: "center", marginBlockEnd: themeSpacing(8) } as StyleObject,
} as DomphyElement<"div">,
]
: []),
{
div: [laptop],
style: { position: "relative", zIndex: 1, width: "100%", perspective: themeSpacing(300) } as StyleObject,
} as DomphyElement<"div">,
],
style: {
position: "sticky",
insetBlockStart: 0,
height: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
} as StyleObject,
} as DomphyElement<"div">,
],
dataTone: "shift-16",
style: {
position: "relative",
minHeight: `${wrapperHeightVh}vh`,
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
...(props.style ?? {}),
} as StyleObject,
_onMount: (node: ElementNode) => {
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return;
const wrapperElement = node.domElement as HTMLElement;
let currentProgress = computePinnedProgress(wrapperElement);
let targetProgress = currentProgress;
let isAnimating = false;
let animationFrameHandle = 0;
paint(currentProgress);
function step(): void {
currentProgress += (targetProgress - currentProgress) * 0.18;
if (Math.abs(targetProgress - currentProgress) < 0.001) {
currentProgress = targetProgress;
paint(currentProgress);
isAnimating = false;
return;
}
paint(currentProgress);
animationFrameHandle = window.requestAnimationFrame(step);
}
function handleScroll(): void {
targetProgress = computePinnedProgress(wrapperElement);
if (!isAnimating) {
isAnimating = true;
animationFrameHandle = window.requestAnimationFrame(step);
}
}
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleScroll);
node.addHook("Remove", () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleScroll);
if (animationFrameHandle) window.cancelAnimationFrame(animationFrameHandle);
});
},
};
}
export { macbookScroll };