npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 zoom interacted incorrectly with font-size, font-weight, font-variant, and font-style on 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-adjust was 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, from transform-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-zoom

ESM 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 zoom gets 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 * zoom can 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 + test

License

MIT