layout-zoom
v0.2.2
Published
React component that scales content up or down and has it actually affect layout — CSS zoom semantics from transform: scale() in an explicitly sized wrapper, without WebKit's zoom font-metric bugs.
Downloads
6,526
Maintainers
Readme
layout-zoom
A React component that scales content up or down and has it actually affect layout.
Why
transform: scale() resizes content but leaves a full-size hole in the layout.
Siblings keep their distance as if nothing changed, because transforms are a
paint-time operation — they never touch layout.
CSS zoom resizes content and participates in layout. That is what you
actually want for previews, thumbnails, and magnified detail views: a card
rendered at half size should occupy half the space, and one rendered at double
size should claim twice as much.
So why not just write zoom: 0.5? Two reasons, below.
Background
1. zoom's compatibility floor is higher than it looks
zoom is old — Chrome 4, Safari 4, Edge 12, Samsung Internet 4, Opera 15. But
it was a non-standard Internet Explorer invention for most of its life, and
Firefox refused to ship it until Firefox 126 (May 2024), when it was
finally standardized in CSS Viewport Module Level 1.
That makes zoom Baseline 2024 — Newly available, with roughly
96% global support. Fine for most projects, but "newly available" is a much
weaker guarantee than the Chrome-4 number suggests, and anything targeting
pre-May-2024 Firefox has no zoom at all.
2. WebKit miscomputed font metrics under zoom for six years
This is the real problem, and it is worse than a rounding error — for a long stretch, iPadOS scaled font sizes in the wrong direction.
| Date | Event |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nov 2019 | First public report on the Apple Developer Forums: on iPadOS 13, zoom: 50% applied to font-size: 20px produced a computed 40px instead of 10px. Inverted. Not reproducible on iPhone or macOS. |
| Apr 2024 | WebKit #272339 filed — zoom fails to scale font-size when combined with -webkit-text-size-adjust: none and an explicit font-size. iOS 17 / Safari 17. Chrome and desktop Safari unaffected. |
| May 2025 | WebKit #293617 filed — the broader iPad case: zoom interacts incorrectly with explicit font-size, font-weight: bold, font-variant: small-caps, and font-style: italic. |
| 2026-05-07 | #272339 closed as a duplicate of #293617. |
| 2026-05-09 | Fix lands: 312944@main (d221d71a6a56, PR #64504), plus 305413.862@safari-7624-branch for the Safari branch. |
| 2026-07-27 | Ships in Safari 26.6. |
Safari 26.6's release notes put it plainly, under CSS → Bug fixes:
Fixed an issue where CSS
zoominteracted incorrectly withfont-size,font-weight,font-variant, andfont-styleon iPad when requesting the desktop website. (176647969)
Root cause
Simon Fraser's comment on #272339 names the setup — one CSS property that means two different things depending on which platform you are on:
-webkit-text-size-adjustwas added independently for macOS and iOS. On macOS it means just this: don't scale the font size with zoom. On iOS it means something different: don't apply font boosting logic.
The fixing PR shows where those divergent semantics actually collided.
updateFontForTextSizeAdjust() computed font sizes without accounting for the
element's zoom factor. When zoom was inherited from an ancestor, the
updateFontForZoomChange() pass that ran afterwards then failed to recalculate
correctly — and since font metrics were already wrong by that point, everything
derived from them (font-variant, font-weight, font-style) rendered wrong
too.
The fix, in StyleBuilderState.cpp, computes the zoom factor inside
updateFontForTextSizeAdjust() — deliberately bypassing the minimum-font-size
clamp, which should never apply to an author-specified text-size-adjust — and
adds an early return to updateFontForZoomChange() so it stops overwriting
sizes that were already correctly zoomed.
In other words: two passes over font size, each assuming the other had not run.
Why the fix doesn't close the case
The trigger — iPad requesting the desktop website — sounds narrow, but iPad serves desktop sites by default for a large fraction of traffic, so in practice this fires on ordinary page loads.
More importantly, the fix shipped on 2026-07-27. Every iPad that has not
taken the 26.6 update still has the bug, and OS-update adoption curves are
measured in quarters. If you ship to iPads today, you are shipping to a
meaningful population where native zoom mangles text.
How layout-zoom works
zoom semantics, without zoom:
transform: scale()on the content, fromtransform-origin: top left- inside a wrapper explicitly sized to the scaled footprint
The wrapper is what restores layout participation — it is a real box of real dimensions, so siblings position against it correctly. The transform does the visual scaling without ever consulting WebKit's font-size resolution, which is where all the bugs live.
Nothing is clipped, and no containment is applied. The scaled content exactly
fills the wrapper, so there is nothing to clip but whatever a consumer paints
outside its own box — focus rings, shadows, popovers — and native zoom would
leave those alone too. If you want them clipped, overflow: hidden on your own
class does it.
The trade-off
Native zoom re-runs layout at the target size, so glyphs are hinted and
rasterized at the size you finally see. transform scales a box that was laid
out at its unscaled size. In practice browsers re-rasterize a statically
transformed layer at its effective scale, so static text generally stays sharp —
but while a transform is animating, the compositor usually rasterizes once and
scales the result, which softens text for the duration. Scaling up is where you
notice it most, since you are enlarging rather than shrinking that raster.
That is the price of correctness here, and it is the right trade: a softer transition beats correct text on most platforms and wrong-sized text on iPad.
Install
pnpm add layout-zoomESM only. react ^19 is a peer dependency, and the only one — nothing else is
imported at runtime.
Size
803 bytes over the wire, once your bundler has done its usual work.
| | | | ------------------------------- | --------- | | Published source, comments kept | 8290 B | | Minified, comments stripped | 2130 B | | Minified + gzip | 953 B | | Minified + brotli | 803 B |
The published files are deliberately unminified and keep their comments: the
build is tsc, not a bundler, so what ships is readable in node_modules and
several of those comments explain why a line that looks removable is not. Your
bundler minifies it along with everything else, so the readable copy costs your
users nothing.
Those numbers are generated, not typed — pnpm size --write measures the built
output and rewrites the table above.
Usage
import { LayoutZoom } from 'layout-zoom'
export const Thumbnail = () => (
<LayoutZoom zoom={0.5} width={320} height={180}>
<Card />
</LayoutZoom>
)Renders a 160 × 90 box in the layout, containing <Card /> drawn at its
natural 320 × 180 and scaled to half size. zoom={2} would instead claim
640 × 360.
Sizing modes
Give both axes, or either one with a ratio:
<LayoutZoom zoom={0.5} width={320} height={180} />
<LayoutZoom zoom={0.5} width={320} aspectRatio='16 / 9' />
<LayoutZoom zoom={0.5} height={180} aspectRatio='16 / 9' />All three produce the same 160 × 90 footprint. The derived axis is resolved by
the layout engine rather than computed here, so it keeps working when a query
overrides one of the properties.
Supplying all three is harmless — with both axes definite the ratio is ignored, which is plain CSS behaviour rather than a rule this component invents.
Driving it from CSS
Sizing reads three custom properties. Props are a convenience that writes them into inline style — anything you can express in CSS works too:
| Property | Meaning | Default |
| ---------------------------- | -------------------------------- | ------- |
| --layout-zoom-zoom | scale factor | 1 |
| --layout-zoom-width | unscaled physical width | — |
| --layout-zoom-height | unscaled physical height | — |
| --layout-zoom-aspect-ratio | derives whichever axis is absent | — |
The arithmetic lives in calc(), not in JS, so overriding a property re-runs it
in the layout engine with no React render involved. That buys you the thing
props cannot express — zoom that responds to a media or container query:
<LayoutZoom className='thumb' width={320} height={180}>
<Card />
</LayoutZoom>.thumb {
--layout-zoom-zoom: 0.5;
}
@container (max-width: 400px) {
.thumb {
--layout-zoom-zoom: 0.25;
}
}A zoom prop, being inline style, always wins over a stylesheet value.
Writing modes
Sizing is physical, not flow-relative, and the scaled content is pinned to the
physical top-left. The box therefore keeps its orientation and geometry under
writing-mode: vertical-rl — a thumbnail should not turn on its side because
the text around it does.
This is deliberate rather than an oversight about logical properties. transform
and transform-origin have no flow-relative form in CSS, so a scaler built on
them cannot honestly follow the writing mode: sizing the box with inline-size
while scaling from a physical origin is precisely what breaks it, leaving the
content anchored to the flow start corner and clipped away entirely.
Animating the zoom
Import the optional stylesheet to register --layout-zoom-zoom via @property:
import 'layout-zoom/styles.css'.thumb {
transition: --layout-zoom-zoom 200ms ease;
}
.thumb:hover {
--layout-zoom-zoom: 0.75;
}Unregistered custom properties are untyped token streams and cannot be
interpolated, so transition is a no-op without this. Note that native CSS
zoom is not animatable at all — this is capability the property being
polyfilled here does not have.
Everything else works without the import.
Element props and refs
LayoutZoom extends ComponentPropsWithRef<'div'>. Unrecognised props spread onto
the outer element, and ref points at it:
<LayoutZoom ref={ref} zoom={0.5} width={320} height={180} onClick={open} data-id='card' />style merges over the component's own layout styles and under the values
derived from zoom / width / height.
Using Motion (previously Framer Motion)
Layout animations do not work inside a zoomed box. If a subtree contains
layout or layoutId, do not scale it with this component.
Motion measures layout in painted space and applies its correction in the
element's local space, compensating only for transforms it applied itself —
treeScale accumulates along its own projection nodes, and a plain CSS
transform ancestor is not one. Inside a zoom of 1.5 the two spaces differ
by exactly that factor, so every delta is applied 1.5× too far.
It is not subtle. In a segmented control whose indicator moves with layoutId,
the indicator leaves the control entirely — measured at 112px outside the range
it should stay within, travelling in the wrong direction first.
Everything else about Motion is fine: animate, gestures, AnimatePresence,
and any transform- or opacity-based animation all behave normally, because none
of them measure the page. This applies specifically to layout animation.
The workarounds do not work either — layoutRoot, and having Motion apply the
scale, were both measured and both fail. What works is not scaling that
subtree: give it zoom: 1 and author it at its final size. The full write-up,
with line-level references into Motion's source, is in
archive/2026-07-motion-layout-animation,
and the Shared layout animation story demonstrates the failure.
MotionValue does work
Everything above is about layout animation. Driving the zoom itself from a
MotionValue is fine, and needs nothing from this component — set the custom
property on any ancestor and leave the zoom prop unset:
export const Zoomable = () => {
const zoom = useMotionValue(1)
return (
<motion.div style={{ '--layout-zoom-zoom': zoom }}>
<LayoutZoom width={320} height={180}>
<Card />
</LayoutZoom>
</motion.div>
)
}The property inherits, so the box picks it up, and the footprint tracks it
frame by frame — siblings reflow as it animates, which is the whole point.
motion.create(LayoutZoom) works too, if you would rather not add a wrapper.
Do not pass a MotionValue to the zoom prop. Props are written into
inline style, which can only hold a string, so the box is sized from
[object Object] and collapses. TypeScript rejects it, and a development-time
warning names the ancestor route if you get past the types.
Leaving zoom unset is required for the ancestor route, not incidental: a prop
lands in inline style and outranks the inherited value.
One unrelated trap while you are here: Motion only rewrites borderRadius to
undo scale distortion when it is set in px or %. An em radius is returned
uncorrected and the corners go square mid-animation.
Limitations
- A size is required, from either props or custom properties. The wrapper
has to know the unscaled size to compute the scaled footprint. Native
zoomgets this for free from layout; a paint-stage polyfill cannot. - Text softens while animating. See the trade-off above.
- Motion layout animations break inside the box. See above.
- Fractional footprints.
width * zoomcan land on a subpixel value; that is intentional — rounding introduces drift when boxes nest.
Development
pnpm install
pnpm storybook # play with the component
pnpm check # lint + format + typecheck + testLicense
MIT
