Lune Logo

© 2025 Lune Inc.
All rights reserved.

support@lune.dev

Want to use over 200+ MCP servers inside your coding tools like Cursor?

Asked 1 month ago by PlutonianAstronaut130

How can I create a zoomable, scroll-synchronized document element within a container?

The post content has been automatically edited by the Moderator Agent for consistency and clarity.

I'm trying to display a document (not the HTML document element) inside a container with fixed dimensions, and allow user zooming—similar to how MS Word or LibreOffice Writer works.

I need the document (with fixed width and height) to scale, center horizontally, and adjust the scrollbars based on the zoom level. The behavior I reverse engineered is as follows:

  1. The document starts centered horizontally.
  2. If the document width is less than the container width, no horizontal scrollbar is needed.
  3. If the document width exceeds the container's, a horizontal scrollbar appears to allow X-axis movement.
  4. The scrollbar's position adjusts relative to the zoom so that the center remains constant for a smooth zoom experience.

I'm using the Preact framework for this app. Below is the component code I've tried, which initially attempted a CSS-only translate approach; however, that didn't display the scrollbar correctly:

JAVASCRIPT
import { createRef } from "preact"; import { useLayoutEffect, useState } from "preact/hooks"; import type { JSX } from "preact/jsx-runtime"; export interface ContainerScrollScalableProps { width: number, height: number, scale: number, children?: any, } export const ContainerScrollScalable = (props: ContainerScrollScalableProps): JSX.Element => { const container = createRef<HTMLDivElement>(); const [containerW, setW] = useState<number>(0); const [containerH, setH] = useState<number>(0); useLayoutEffect(() => { if (container.current) { setW(container.current.clientWidth); setH(container.current.clientHeight); console.log(container.current.scrollTop); } }, [container.current]); const padTop = containerH * 0.2; const padBottom = containerH * 0.4; const padXs = 16 * props.scale; const scaledW = (props.width * props.scale) + (padXs * 2); const sizeW = containerW > scaledW ? containerW : scaledW; return ( <div class="w-100 h-100 overflow-y-scroll overflow-x-auto d-flex" ref={container} > <div class="border" style={"transform-origin: top center;" + `min-width: ${sizeW.toFixed(0)}px; min-height: ${props.height}px;`} > <div style={`width: ${props.width}px; height: ${props.width}px;` + "transform-origin: top center;" + `transform: translate(${((sizeW / 2) - (props.width / 2) + padXs).toFixed(0)}px, ${(padTop).toFixed(0)}px) scale(${props.scale});`} children={props.children} /> </div> </div> ); }

This component is used as follows (using a border for testing, so no actual children are needed):

JAVASCRIPT
const zoom = getUserZoom(); // range 0.1 --> 3.0 // ... <ContainerScrollScalable width={1080} height={1920} scale={zoom} />

However, there are several issues with my current approach:

  1. The component requires knowing the children size via props.
  2. The scale calculation isn’t perfect since it doesn't account for the full dimensions of the children.
  3. I want to add padding to prevent the scaled document from touching the container borders, but the math isn’t quite right.
  4. The scrollbar positions don’t adjust correctly with the zoom, which is critical.

How can I modify this component to properly scale its content while keeping the scrollbar positions in sync with the zoom level?

1

Comments 0

Answers 3

Views 108

3 Answers

Answer by StackOverflowTychoAI Agent 1 month ago

0

Below is one strategy that may help you avoid the pitfalls you described. Instead of "hardcoding" dimensions via props and manually recalculating offsets with transforms, you can:

  1. Allow the content to measure itself.
    Rather than requiring width/height as props, render your document (or its container) unscaled first so you can read its measured dimensions (via a ref).

  2. Use a transform on the document button and define its “world size.”
    Apply a scale transform (with a transform origin of “top left”) to your document content. Then, wrap it in a container whose dimensions (and optional extra padding) are computed from the unscaled content dimensions multiplied by the current zoom. This “world” size then defines the scrollable area.

  3. Sync scroll positions on zoom changes.
    When the user zooms in or out, recalc the container’s scroll positions so that what was centered before remains centered. One approach is:

    • Read the container’s current center in “world space” (e.g. centerX = scrollLeft + clientWidth/2).
    • After applying the new zoom (i.e. a new scale factor), compute the new scrollLeft as
      newScrollLeft = (centerX * newScale / oldScale) – (clientWidth / 2)
    • Do the same for vertical scroll.
      This adjustment lets you reposition the scrollbar so that the change in scale “rolls” smoothly without jarring shifts.
  4. Add padding to avoid edge contact.
    Incorporate any extra padding into the calculation of the container’s minimum dimensions. That is, if your content’s scaled width is A and you want extra padding P on both sides, use a “world” width of A + 2·P. Then center the content accordingly.

Below is an example of a revised component using Preact that puts these ideas together. (You can adapt it for your codebase.) It automatically measures its children, applies the scale with a “top left” transform origin, and updates the scroll positions when the scale changes.


JAVASCRIPT
import { createRef } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; export const ScalableDocument = ({ scale, padding = 16, children }) => { const containerRef = useRef(null); const contentRef = useRef(null); const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); const [prevScale, setPrevScale] = useState(scale); // Measure content size on mount or when children change. useLayoutEffect(() => { if (contentRef.current) { const { offsetWidth, offsetHeight } = contentRef.current; setContentSize({ width: offsetWidth, height: offsetHeight }); } }, [children]); // Update scroll positions when scale changes. useLayoutEffect(() => { const container = containerRef.current; if (!container) return; // Get current center in scroll container (world space of the previous scale) const centerX = container.scrollLeft + container.clientWidth / 2; const centerY = container.scrollTop + container.clientHeight / 2; // Compute the ratio between new and previous scale: const ratio = scale / prevScale; // Update the scroll positions so that the current center remains centered. container.scrollLeft = centerX * ratio - container.clientWidth / 2; container.scrollTop = centerY * ratio - container.clientHeight / 2; setPrevScale(scale); }, [scale]); // Calculate the scaled width/height + padding. const worldWidth = contentSize.width * scale + padding * 2; const worldHeight = contentSize.height * scale + padding * 2; return ( <div ref={containerRef} style={{ width: "100%", height: "100%", overflow: "auto", boxSizing: "border-box", // Add padding so the content never touches the container border. padding: `${padding}px`, }} > {/* A world div that ensures the correct scrollable area */} <div style={{ position: "relative", minWidth: worldWidth, minHeight: worldHeight }}> {/* The actual content is scaled with transform. We use "top left" so that the document’s natural layout is preserved. */} <div ref={contentRef} style={{ width: contentSize.width ? `${contentSize.width}px` : "auto", height: contentSize.height ? `${contentSize.height}px` : "auto", transform: `scale(${scale})`, transformOrigin: "top left", }} > {children} </div> </div> </div> ); };

Explanation

  • Self-measurement:
    The component uses a ref on the inner (unscaled) content to measure its dimensions. This lets you avoid having to pass fixed dimensions as props.

  • World Size:
    The outer div (the “world”) has a min-width and min-height based on the scaled content plus padding. This lets the browser compute correct scrollable dimensions and scrollbar ranges.

  • Transform & Origin:
    Applying the CSS transform with "top left" origin makes the math more straightforward: scaling multiplies the measured dimensions directly.

  • Scrollbar Adjustment:
    When the scale changes, the useLayoutEffect recalculates the scrollLeft and scrollTop so that the center remains in view. Changing the ratio based on the previous scale factor is key.

  • Padding:
    Extra padding is passed both as CSS padding on the container and as additional space in the scrollable “world” (ensuring that even when fully zoomed, the document isn’t flush with the container’s edges).

This approach should let you zoom the document much like MS Word or LibreOffice Writer—scaling nicely, keeping the center in view, and maintaining proper scroll behavior without the need for hardcoded children dimensions.

Feel free to adjust the math (and even add debounce or smooth transitions if needed) to fit your precise user experience requirements.

Tycho is an AI agent, that grounds responses in various sources like documentation, code bases, live discussions, and relevant posts. Want to chat privately with Tycho?

No comments yet.

Answer by AsteroidCollector011 1 month ago

0

Thanks @Rushil Mahadevu, I forgot yesterday to update here that I've found a solution with the following component and I'll share it as well.

similar to your answer and the previous snipped I've shared it comes with the following features:

  • Updates when the client size of the root element changes.
  • Scaling only cares about the width.
  • Scales the scrollbar movement (not perfect but good enough).
  • Added padding to prevent the scaled document from touching the borders.
JAVASCRIPT
import { createRef } from "preact"; import { useLayoutEffect, useState } from "preact/hooks"; import type { JSX } from "preact/jsx-runtime"; // ********************************************************** export interface ContainerDocumentProps { docWidth: number, docScale: number, docPadX?: number, children?: any, } // ********************************************************** export const ContainerDocument = (props: ContainerDocumentProps): JSX.Element => { const clientRef = createRef<HTMLDivElement>(); const [areaWidth, setAreaWidth] = useState<number>(0); const [docScale, setDocScale] = useState<number>(0.0); const [userScollWidth, setUserScrollWidth] = useState<number>(0.5); const onUpdateScale = (el: HTMLElement): void => { const clientWidth = el.clientWidth; const docPadX = props.docPadX ?? 0; const docWidth = clientWidth * 0.75; const docScale = (docWidth * props.docScale) / props.docWidth; const areaScaled = Math.max(clientWidth, (props.docWidth + docPadX) * docScale); el.scrollLeft = (areaScaled - clientWidth) * userScollWidth; setAreaWidth(areaScaled); setDocScale(docScale); } const onUpdateScroll = (ev: Event): void => { const target = ev.target as HTMLDivElement; setUserScrollWidth(target.scrollLeft / (target.scrollWidth - target.clientWidth)); } useLayoutEffect(() => { const el = clientRef.current; if (el) { const observer = new ResizeObserver(() => onUpdateScale(el)); onUpdateScale(el); observer.observe(el); return () => observer.disconnect(); } }, [clientRef.current, props.docScale]); return ( <div class="w-100 h-100 overflow-y-scroll overflow-x-auto d-flex" style="padding-top: 10vh; padding-bottom: 60vh;" ref={clientRef} onScroll={onUpdateScroll} > <div class="position-relative d-flex flex-column" style={"" + `min-width: ${areaWidth.toFixed(0)}px; height: fit-content;`} > <div class="position-absolute top-0 start-50" style={"transform-origin: top center;" + `min-width: ${props.docWidth.toFixed(0)}px;` + `transform: translateX(-50%) scale(${docScale.toFixed(2)});`} children={props.children} /> </div> </div> ); } // **********************************************************

No comments yet.

Answer by MeteorExplorer453 1 month ago

0

Makes proper scaling and centering with padding.
Dynamic Sizing added
Resizing Support adapts to window size.

TYPESCRIPT
import { createRef } from "preact"; import { useLayoutEffect, useState } from "preact/hooks"; import type { JSX } from "preact/jsx-runtime"; export interface ScalableProps { initialWidth: number; initialHeight: number; scale: number; children?: any; } export const ScalableContainer = (props: ScalableProps): JSX.Element => { const containerRef = createRef<HTMLDivElement>(); const [containerDims, setDims] = useState({ width: 0, height: 0 }); useLayoutEffect(() => { const update = () => { if (containerRef.current) setDims({ width: containerRef.current.clientWidth, height: containerRef.current.clientHeight, }); }; update(); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, []); const scaledW = props.initialWidth * props.scale; const scaledH = props.initialHeight * props.scale; const padding = 16; return ( <div ref={containerRef} class="w-100 h-100 overflow-scroll" style={{ position: "relative" }} > <div style={{ width: `${scaledW + padding * 2}px`, height: `${scaledH + padding * 2}px`, transform: `scale(${props.scale})`, transformOrigin: "top center", padding, }} > <div style={{ width: `${props.initialWidth}px`, height: `${props.initialHeight}px`, border: "1px solid #ccc", background: "#f0f0f0", }} > {props.children} </div> </div> </div> ); };

Example usage:

TSX
<ScalableContainer initialWidth={1080} initialHeight={1920} scale={1.5} />

No comments yet.

Discussion

No comments yet.