How This Works
+ ++ A deep dive into the internal architecture and design decisions behind + shimmer-from-structure. +
+ +The Problem
+ ++ Building modern web applications means dealing with asynchronous data. While your app + fetches user profiles, product listings, or dashboard metrics from an API, users stare at + blank screens or spinners. The industry solution? Loading skeletons - placeholder UI that + mimics the structure of the content to come. +
+ ++ But here's the catch: writing loading skeletons is{' '} + repetitive, error-prone, and a maintenance nightmare. Every time you + build a new component, you write it twice - once for the real content, and once for the + skeleton. Change the layout? Update both versions. Add a new field? Don't forget the + skeleton. The two implementations drift apart, and suddenly your loading state looks + nothing like your actual UI. +
+ ++ Yet the skeleton structure you're manually recreating already exists - + it's right there in the rendered DOM. Your component knows how to lay itself out. It knows + where the heading goes, how wide the text blocks are, where the avatar sits. The browser + has already calculated every dimension, every position, every spacing rule. +
+ +
+ This is the core insight behind shimmer-from-structure:{' '}
+ if the structure already exists in the DOM, why not measure it? Instead
+ of maintaining parallel skeleton components, we can render the real component once, read
+ its dimensions using browser APIs like getBoundingClientRect(), and generate
+ pixel-perfect shimmer overlays automatically. No duplication. No drift. No manual
+ maintenance.
+
The Core Idea
+ +
+ At its heart, shimmer-from-structure uses a simple but powerful technique:
+ runtime DOM measurement. When you wrap a component in{' '}
+
+ <Shimmer loading={'{'}true{'}'}>
+
+ , the library renders your component normally, then walks through the resulting DOM tree
+ and calls getBoundingClientRect() on each element.
+
+ This browser API returns the exact position and dimensions of every element as the browser
+ has calculated them - accounting for CSS, flexbox, grid, responsive breakpoints, dynamic
+ content, everything. We capture the top, left,{' '}
+ width, and height of each text node, image, button, and
+ container.
+
+ Once we have these measurements, we create{' '}
+ absolutely-positioned shimmer overlays that sit on top of the real
+ content. Each overlay is a <div> with:
+
-
+
-
+
position: absoluteto remove it from document flow +
+ -
+
topandleftvalues matching the measured element's position +
+ -
+
widthandheightmatching the measured element's dimensions +
+ -
+ A shimmer animation (CSS gradient with
background-position+ keyframes) +
+
+ The real content underneath is made transparent using color: transparent (not{' '}
+ opacity: 0, which would hide backgrounds and borders). This means the shimmer
+ blocks appear exactly where your text, images, and UI elements will be - because they're
+ positioned based on where those elements actually are.
+
+ When loading becomes false, the shimmer overlays are removed,
+ the content becomes visible again, and your component displays normally. The transition is
+ seamless because the layout never changed - the shimmer was always matching the real
+ structure.
+
Design Constraints
+ ++ While the runtime DOM measurement approach is powerful, it comes with important + constraints that shaped the library's design and implementation. Understanding these + limitations helps explain why certain architectural decisions were made and how the + library achieves its performance goals. +
+ +SSR Environments
+ +
+ The library's core technique - calling getBoundingClientRect()
+ on DOM elements - fundamentally requires a browser environment. This API doesn't exist in
+ server-side rendering (SSR) contexts like Next.js's getServerSideProps, Remix
+ loaders, or Node.js environments.
+
+ This means{' '} + shimmer-from-structure cannot generate shimmer overlays during SSR. The + measurement phase must happen client-side, after the component has mounted and the browser + has calculated layout. For SSR frameworks, this is acceptable because: +
+ +-
+
- + The shimmer is a loading state - it only appears while fetching data, + which typically happens client-side anyway + +
- + SSR delivers the initial HTML shell quickly; the shimmer activates during client-side + data fetching + +
- + The library detects SSR environments and safely skips measurement, preventing runtime + errors + +
+ All framework adapters include SSR guards that check for{' '}
+ typeof window !== 'undefined' before attempting DOM measurement. This ensures
+ the library works seamlessly in SSR frameworks without requiring special configuration.
+
Frame Budget
+ ++ Browsers render at 60 frames per second (fps), which means each frame has a budget of + approximately 16.67 milliseconds. If JavaScript execution, layout + calculations, or painting take longer than this, the browser drops frames, causing visible + stuttering or flicker. +
+ +
+ The measurement phase - where the library walks the DOM tree and calls{' '}
+ getBoundingClientRect() on each element - must complete within this frame
+ budget. If measurement takes too long, users will see a flash of unstyled content before
+ the shimmer appears.
+
To stay within the frame budget, the library:
+ +-
+
- + Minimizes DOM traversal overhead by using efficient tree walking + algorithms + +
- + Batches measurements to trigger only one browser reflow (see + "Minimizing Reflows" section) + +
-
+ Skips unnecessary elements using
data-shimmer-ignore+ anddata-shimmer-no-childrenattributes +
+ - + Caches computed styles to avoid redundant style calculations + +
+ For typical component trees (dozens to hundreds of elements), measurement completes in
+ 2-5ms, well within the frame budget. For extremely large trees (thousands of elements),
+ developers can use data-shimmer-no-children
+ to treat complex subtrees as single shimmer blocks, reducing measurement overhead.
+
Reflow Minimization
+ ++ A reflow (also called layout recalculation) occurs when the browser + recalculates the position and dimensions of elements in the document. Reflows are + expensive operations that can take several milliseconds, especially for complex layouts. +
+ +
+ Reading layout properties like getBoundingClientRect(),{' '}
+ offsetWidth, or getComputedStyle() forces the browser to perform
+ a reflow if any DOM changes have occurred since the last layout. Worse, interleaving DOM
+ writes (changing styles or content) with DOM reads (measuring dimensions) causes{' '}
+ multiple reflows - a performance anti-pattern known as "layout
+ thrashing."
+
+ The library's measurement strategy is designed to trigger only one reflow{' '} + per measurement cycle: +
+ +-
+
-
+ Apply all CSS changes first (set
color: transparent, + inject measurement styles) without reading any layout properties +
+ -
+ Perform all measurements in a single pass, reading{' '}
+
getBoundingClientRect()on every element without making any DOM changes +
+ - + Render shimmer overlays using the captured measurements, which doesn't + affect the measured elements' layout + +
+ This batching strategy ensures that even complex component trees with hundreds of elements + trigger only one reflow, keeping the measurement phase fast and preventing visual flicker. + The "Edge Case: Table Cells" section below describes a specific optimization where this + batching approach was critical for performance. +
+ +Developer API Design
+ ++ With the technical constraints understood, the next challenge was designing an API that + developers would actually want to use. The goal was to make shimmer loading{' '} + trivially easy to add to any project - no configuration files, no build + steps, no complex setup. Just wrap your component and get pixel-perfect loading states. +
+ +
+ The API design centers on a simple wrapper pattern: the <Shimmer>
+ component. You wrap any component you want to shimmer, pass a loading
+ boolean, and the library handles the rest:
+
+ {`import { Shimmer } from '@shimmer-from-structure/react';
+
+function UserProfile({ userId }) {
+ const { data: user, isLoading } = useQuery(['user', userId], fetchUser);
+
+ return (
+
+
+
+ {user?.name}
+ {user?.bio}
+
+
+ );
+}`}
+
+
+
+ When loading is true, the library measures the child component
+ and renders shimmer overlays. When loading becomes false, the
+ shimmer disappears and the real content shows. No separate skeleton component to maintain,
+ no layout duplication, no drift between loading and loaded states.
+
Handling Dynamic Data with templateProps
+ +
+ But there's a problem: what if your component needs data to render? In the example above,{' '}
+ user is undefined while loading, so the component would render
+ empty - giving the measurement phase nothing to measure.
+
+ This is where templateProps comes in. You provide mock data that gets spread
+ onto the child component during the measurement phase:
+
+ {`import { Shimmer } from '@shimmer-from-structure/react';
+
+const mockUser = {
+ avatar: 'https://via.placeholder.com/150',
+ name: 'John Doe',
+ bio: 'Software engineer and open source contributor.',
+};
+
+function UserProfile({ userId }) {
+ const { data: user, isLoading } = useQuery(['user', userId], fetchUser);
+
+ return (
+
+
+
+ {user?.name}
+ {user?.bio}
+
+
+ );
+}`}
+
+
+
+ During measurement, the library clones the child component and spreads{' '}
+ templateProps onto it, so the component renders with mock data. The browser
+ calculates layout based on this mock content, and the library captures those dimensions.
+ When loading becomes false, the real data replaces the mock
+ data, and because the layout structure is the same, the transition is seamless.
+
Design Choice: One Child at a Time
+ +
+ The <Shimmer> component accepts{' '}
+ exactly one child that accepts props. This is a deliberate design
+ constraint that keeps the API simple and predictable:
+
-
+
-
+ Clear prop spreading: The library knows exactly where to spread{' '}
+
templateProps- onto the single child component +
+ - + Predictable behavior: Developers don't have to guess which child + receives which props + +
- + Composability: If you need to shimmer multiple components, wrap each + one individually or wrap a parent container + +
- + Framework compatibility: This pattern works consistently across React, + Vue, Svelte, Angular, and Solid + +
+ This constraint trades flexibility for simplicity. You can't wrap multiple sibling
+ components in a single <Shimmer>, but in practice, this is rarely
+ needed - and when it is, wrapping a parent container works just as well. The benefit is an
+ API that's immediately understandable and works the same way in every framework.
+
Architecture Decision
+ ++ One of the most important architectural decisions in shimmer-from-structure was how to + support multiple JavaScript frameworks (React, Vue, Svelte, Angular, SolidJS) without + duplicating the core measurement and shimmer logic. The solution is a{' '} + + monorepo architecture with a framework-agnostic core package and framework-specific + adapter packages + + . +
+ +Core Package
+ +
+ The @shimmer-from-structure/core package contains all the framework-agnostic
+ DOM measurement and shimmer logic. This includes:
+
-
+
-
+
+
extractElementInfo()+ {' '} + - ReadsgetBoundingClientRect()and computed styles on a DOM element to + produce anElementInfoobject with position, dimensions, and border radius +
+ -
+
+
isLeafElement()+ {' '} + - Determines whether an element should receive a shimmer block (ignores void elements + like<br>,<wbr>,<hr>) +
+ -
+
+
createResizeObserver()+ {' '} + - SharedResizeObserverutility withrequestAnimationFrame+ throttling for responsive shimmer updates +
+ -
+
+
SHIMMER_CONTAINER_STYLES+ {' '} + - CSS string applied to measurement containers, handling{' '} +data-shimmer-ignoreanddata-shimmer-no-childrenattribute + exclusions +
+ -
+
+
shimmerDefaults+ {' '} + - Default configuration values (colors, duration, border radius) shared across all + adapters +
+ -
+ TypeScript types - Shared interfaces like
ElementInfo,{' '} +ShimmerConfig, andShimmerContextValue+
+
+ The key insight is that{' '}
+ all of these utilities use cross-framework browser APIs.{' '}
+ getBoundingClientRect(), getComputedStyle(),{' '}
+ ResizeObserver, and DOM traversal work identically in React, Vue, Svelte,
+ Angular, and SolidJS. There's no framework-specific logic in the core package - it's pure
+ DOM manipulation.
+
+ This design means that bug fixes, performance optimizations, and new features in the + measurement logic only need to be implemented once. When the table cell batching + optimization (described in the "Edge Case: Table Cells" section) was added to core, all + five framework adapters immediately benefited without any adapter-specific changes. +
+ +Framework Adapters
+ +
+ Each framework adapter (@shimmer-from-structure/react,{' '}
+ @shimmer-from-structure/vue, @shimmer-from-structure/svelte,{' '}
+ @shimmer-from-structure/angular, @shimmer-from-structure/solid)
+ is a thin wrapper around the core package. Adapters are responsible for:
+
-
+
-
+ Hooking into framework-specific rendering lifecycles - React uses{' '}
+
useLayoutEffect(synchronous, before paint), Vue useswatch{' '} + andnextTick, Svelte uses$effectandonMount, + Angular usesngAfterViewInit, and SolidJS usescreateEffect+
+ -
+ Managing component state - Each framework has its own reactivity system
+ (React's
useState, Vue'sref, Svelte's runes, Angular's + signals, SolidJS's signals) +
+ -
+ Providing global configuration - React uses Context API (
+
ShimmerProvider), Vue usesprovide/inject, Svelte uses{' '} +setContext/getContext, Angular uses dependency injection ( +provideShimmerConfig), and SolidJS usescreateContext+
+ - + Rendering shimmer overlays - Each framework has its own templating + syntax (JSX, Vue templates, Svelte templates, Angular templates) + +
-
+ Handling SSR detection - Checking for{' '}
+
typeof window !== 'undefined'before calling core measurement functions +
+
+ The adapters import core utilities and call them at the appropriate points in each
+ framework's lifecycle. For example, the React adapter calls{' '}
+ extractElementInfo() inside a useLayoutEffect hook, which runs
+ synchronously before the browser paints, preventing visual flicker. The Vue adapter calls
+ the same function inside a watch callback with nextTick for DOM
+ updates. The measurement logic is identical - only the timing and lifecycle integration
+ differs.
+
This separation between core logic and framework-specific code has several benefits:
+ +-
+
- + Consistency: All frameworks get the same measurement behavior, shimmer + animation, and configuration options + +
- + Maintainability: Core logic changes don't require updating five + separate adapters + +
- + Testability: Core utilities can be unit tested in isolation without + framework-specific test setup + +
- + Extensibility: Adding support for a new framework only requires writing + a thin adapter - the core logic is already done + +
+ The architecture can be visualized as a core package with multiple framework adapters + depending on it: +
+ +
+ {`
+┌─────────────────────────────────────────┐
+│ @shimmer-from-structure/core │
+│ (extractElementInfo, isLeafElement, │
+│ createResizeObserver, etc.) │
+└─────────────────┬───────────────────────┘
+ │
+ ┌─────────┼─────────┬─────────┬─────────┐
+ │ │ │ │ │
+ ▼ ▼ ▼ ▼ ▼
+ ┌───────┐ ┌───────┐ ┌────────┐ ┌─────────┐ ┌───────┐
+ │ React │ │ Vue │ │ Svelte │ │ Angular │ │ Solid │
+ └───────┘ └───────┘ └────────┘ └─────────┘ └───────┘
+ `}
+
+
+ + This architecture ensures that shimmer-from-structure can support any JavaScript framework + without compromising on consistency, performance, or maintainability. The core package + handles the complex DOM measurement and reflow optimization logic, while adapters focus + solely on framework integration - a clean separation of concerns that scales as new + frameworks are added. +
+ +Handling Real-World Data
+ ++ The measurement approach works beautifully for static components, but real-world + applications present a challenge:{' '} + components typically render dynamic data from APIs. A user profile card + doesn't have hardcoded text - it displays a name, avatar, and bio fetched from a backend. + A product listing shows items loaded from a database. A dashboard renders metrics pulled + from analytics services. +
+ +This creates a chicken-and-egg problem for shimmer measurement.
+ +The Problem
+ +
+ When loading is true, the data hasn't arrived yet. If your
+ component expects a user prop and that prop is undefined, the
+ component might render nothing - or worse, crash with a null reference error. Either way,
+ the measurement phase has nothing to measure. An empty component produces zero-dimension
+ measurements, resulting in no shimmer blocks at all.
+
Consider this typical React component:
+ +
+ {`function UserCard({ user }) {
+ return (
+
+
+ {user.name}
+ {user.bio}
+ {user.role}
+
+ );
+}`}
+
+
+
+ If user is undefined, this component will throw an error trying
+ to access user.avatar. Even with optional chaining (user?.avatar
+ ), the component renders empty content, giving the measurement phase nothing to work with.
+
You could add conditional rendering to handle the loading state:
+ +
+ {`function UserCard({ user }) {
+ if (!user) {
+ return Loading...;
+ }
+
+ return (
+
+
+ {user.name}
+ {user.bio}
+ {user.role}
+
+ );
+}`}
+
+
+ + But now you're back to square one - you've created a separate loading state that doesn't + match the real component structure. The whole point of shimmer-from-structure is to avoid + this duplication. +
+ +The Solution
+ +
+ The solution is templateProps - a way to provide{' '}
+ mock data during the measurement phase so the component can render with
+ realistic content, allowing the library to capture accurate dimensions. The mock data is
+ only used internally for measurement; it never appears to the user.
+
Here's how it works in practice:
+ +
+ {`import { Shimmer } from '@shimmer-from-structure/react';
+
+// Define mock data that matches the shape of real API data
+const mockUser = {
+ avatar: 'https://via.placeholder.com/150',
+ name: 'John Doe',
+ bio: 'Software engineer and open source contributor with 6 years of experience.',
+ role: 'Senior Developer',
+};
+
+function UserProfile({ userId }) {
+ // Fetch real user data from API
+ const { data: user, isLoading } = useQuery(['user', userId], fetchUser);
+
+ return (
+
+
+
+ );
+}
+
+function UserCard({ user }) {
+ return (
+
+
+ {user.name}
+ {user.bio}
+ {user.role}
+
+ );
+}`}
+
+
+
+ When loading is true, the library:
+
-
+
-
+ Clones the
<UserCard>component +
+ -
+ Spreads
templatePropsonto it, so it receives{' '} ++ user={'{'}mockUser{'}'} ++
+ - Renders the component with mock data in a hidden measurement container +
-
+ Measures the resulting DOM structure using
getBoundingClientRect()+
+ - Generates shimmer overlays based on those measurements +
-
+ Renders the real component (which receives{' '}
+
+ user={'{'}mockUser{'}'} ++ from the fallback) with shimmer overlays on top +
+
+ When loading becomes false, the shimmer overlays are removed,
+ and the component displays the real data. Because the mock data and real data have the
+ same structure (same fields, similar text lengths), the layout remains consistent, and the
+ transition is seamless.
+
+ The user ?? mockUser fallback in the example ensures the component always
+ receives valid data, preventing null reference errors during the loading state. This
+ pattern works across all supported frameworks - React, Vue, Svelte, Angular, and SolidJS.
+
Why One Child at a Time?
+ +
+ You might wonder why <Shimmer> only accepts a single child component
+ that accepts props. This design constraint exists for a practical reason:
+ prop spreading needs an unambiguous target.
+
+ If <Shimmer> accepted multiple children, which one should receive{' '}
+ templateProps? The first? All of them? What if they expect different prop
+ shapes? The API would become confusing and error-prone.
+
By restricting to one prop-accepting child, the behavior is predictable:
+ +-
+
-
+ Clear semantics:
templatePropsalways goes to the single + child component +
+ - + Type safety: TypeScript can infer the correct prop types + +
- + Framework consistency: The pattern works identically in React, Vue, + Svelte, Angular, and SolidJS + +
- + Composability: If you need to shimmer multiple components, wrap each + one individually or wrap a parent container + +
+ This constraint trades flexibility for simplicity and predictability - a deliberate design + choice that makes the library easier to use correctly and harder to use incorrectly. +
+ +Minimizing Reflows
+ ++ Performance is critical for loading states. If the shimmer takes too long to appear or + causes visible stuttering, users will notice - and the experience degrades. One of the + most important performance optimizations in shimmer-from-structure is{' '} + minimizing browser reflows during the measurement phase. +
+ +What are Reflows?
+ ++ A reflow (also called layout recalculation or layout thrashing when it + happens repeatedly) is a browser operation that recalculates the position and dimensions + of elements in the document. When you change an element's size, position, or content, the + browser must recalculate the layout of that element and potentially all of its descendants + and ancestors. +
+ ++ Reflows are expensive. For complex layouts with hundreds or thousands of elements, a + single reflow can take several milliseconds. This matters because: +
+ +-
+
- + Reflows block the main thread - JavaScript execution pauses while the + browser recalculates layout + +
- + Reflows cause visual flicker - if measurement takes too long, users see + a flash of unstyled content before the shimmer appears + +
- + Multiple reflows compound - interleaving DOM writes and reads forces + the browser to reflow repeatedly, multiplying the performance cost + +
+ Reading layout properties like getBoundingClientRect(),{' '}
+ offsetWidth, clientHeight, or getComputedStyle(){' '}
+ forces the browser to perform a reflow if any DOM changes have occurred since the last
+ layout calculation. This is called a forced synchronous layout.
+
+ The worst-case scenario is layout thrashing - alternating between DOM + writes (changing styles or content) and DOM reads (measuring dimensions). Each read forces + a reflow to get accurate measurements, then the next write invalidates the layout, and the + cycle repeats. This can easily consume tens or hundreds of milliseconds, causing visible + performance problems. +
+ +Base Case
+ ++ The library's measurement strategy is designed to trigger{' '} + only one reflow per measurement cycle, regardless of how many elements + are being measured. This is achieved through careful batching of DOM operations into three + distinct phases: +
+ +-
+
-
+ Write Phase: Apply all CSS changes first - set{' '}
+
color: transparenton text elements, inject measurement container styles, + applydata-shimmer-ignoreexclusions - without reading any layout + properties +
+ -
+ Read Phase: Perform all measurements in a single pass, calling{' '}
+
getBoundingClientRect()on every element that needs to be measured, without + making any DOM changes +
+ - + Render Phase: Generate and render shimmer overlays using the captured + measurements, which doesn't affect the measured elements' layout since overlays are + absolutely positioned + +
+ This batching strategy ensures that the browser only needs to recalculate layout once - at + the start of the read phase, after all CSS changes have been applied. Even if you're + measuring a component tree with hundreds of elements, the library triggers only one + reflow. +
+ ++ For typical component trees (dozens to hundreds of elements), the entire measurement cycle + completes in 2-5 milliseconds, well within the 16.67ms frame budget for 60fps rendering. + This means the shimmer appears instantly without any visible flicker or stuttering. +
+ ++ The "Edge Case: Table Cells" section (if implemented) describes a specific scenario where + this batching approach was critical - measuring table cell text required temporarily + injecting span elements, and the naive sequential approach caused multiple reflows. The + optimized solution applies the same three-phase batching pattern to achieve one reflow + even for complex table layouts. +
+ +ResizeObserver
+ ++ The measurement phase handles the initial shimmer rendering, but what happens when the + window resizes? Responsive layouts change dimensions at different breakpoints - a + three-column grid might become two columns on tablets and one column on mobile. The + shimmer needs to update to match the new layout. +
+ ++ The library uses the ResizeObserver API to detect when the measured + container's dimensions change. When a resize is detected, the library re-measures the + component and updates the shimmer overlays to match the new layout. +
+ +
+ Critically, ResizeObserver callbacks are automatically batched by the browser
+ and fire after layout has been calculated but before paint. This means:
+
-
+
-
+ No forced synchronous layouts - the browser has already calculated the
+ new layout when the callback fires, so reading
getBoundingClientRect(){' '} + doesn't trigger an additional reflow +
+ - + Automatic batching - if multiple elements resize simultaneously (common + during window resize), the browser batches all resize notifications into a single + callback invocation + +
- + Optimal timing - the callback fires at the ideal moment to read layout + properties without causing performance issues + +
+ The library further optimizes resize handling by throttling updates using{' '}
+ requestAnimationFrame. This ensures that even if the user rapidly resizes the
+ window, shimmer updates are limited to once per frame (60fps), preventing unnecessary
+ re-measurements and keeping the UI responsive.
+
+ The createResizeObserver utility in the core package implements this
+ optimization and is shared across all framework adapters (React, Vue, Svelte, Angular,
+ SolidJS). This means every framework gets the same efficient resize handling without
+ duplicating the throttling logic.
+
+ The combination of one-reflow measurement and efficient resize handling ensures that + shimmer-from-structure maintains excellent performance even in complex, responsive + layouts. Users never see flicker or stuttering, and the shimmer always matches the current + layout - regardless of screen size or window dimensions. +
+ +Edge Case: Table Cells
+ ++ While the three-phase batching strategy works well for most elements, table cells + presented a unique challenge that required a specialized optimization. This edge case + demonstrates how the reflow minimization principles apply even to complex scenarios. +
+ +The Problem
+ +
+ When measuring table cells (<td> and <th>
+ elements), we want to capture the dimensions of the text content, not the
+ entire cell. This is because table cells often have padding, and measuring the full cell
+ would create shimmer blocks that extend into the padding area, looking visually incorrect.
+
Consider a typical table cell:
+ +
+ {`
+ Product Name
+ `}
+
+
+
+ If we measure the <td> element directly using{' '}
+ getBoundingClientRect(), we get the dimensions of the entire cell including
+ the 12px padding on all sides. The shimmer block would cover the padding area, creating a
+ visual mismatch - the shimmer would be larger than the actual text.
+
+ What we really want is to measure just the text content, excluding the cell's padding. But
+ text nodes don't have getBoundingClientRect() - only elements do. We need a
+ way to measure the text dimensions without measuring the cell's padding.
+
Initial Approach
+ +
+ The naive solution is to temporarily wrap the text content in a <span>{' '}
+ element, measure the span, then remove it:
+
+ {`// For each table cell with text-only content:
+const span = document.createElement('span');
+span.style.display = 'inline';
+
+// Move text into span
+while (cell.firstChild) {
+ span.appendChild(cell.firstChild);
+}
+cell.appendChild(span);
+
+// Measure the span (not the cell)
+const rect = span.getBoundingClientRect();
+
+// Remove the span and restore text
+while (span.firstChild) {
+ cell.insertBefore(span.firstChild, span);
+}
+cell.removeChild(span);`}
+
+
+ + This approach works correctly - the span wraps only the text content, so measuring it + gives us the text dimensions without the cell's padding. However, there's a critical + performance problem: this creates multiple reflows. +
+ ++ If you process table cells sequentially - wrap, measure, unwrap, repeat - you're + interleaving DOM writes (wrapping/unwrapping) with DOM reads (measuring). Each measurement + forces a reflow because the previous wrap operation invalidated the layout. For a table + with dozens of cells, this could trigger dozens of reflows, causing visible performance + degradation. +
+ +Optimized Solution
+ ++ The solution is to apply the same three-phase batching pattern used for the overall + measurement strategy. Instead of processing cells sequentially, we batch all table cell + operations into three distinct phases: +
+ +-
+
-
+ Phase 1 - Writes Only: Traverse the DOM tree, identify all text-only
+ table cells, and wrap their content in
<span>+ elements. Collect references to the wrapped cells for later measurement. Do not call{' '} +getBoundingClientRect()yet. +
+ -
+ Phase 2 - Measurements: Measure all wrapped spans (and all other leaf
+ elements) in a single pass. The first
getBoundingClientRect()+ call triggers one reflow, and subsequent calls use the cached layout. +
+ - + Phase 3 - Cleanup: Remove all temporary span wrappers and restore the + original text nodes. This happens after all measurements are complete, so it doesn't + affect the captured dimensions. + +
+ This batching approach ensures that only one reflow occurs, regardless of + how many table cells need to be measured. Even a complex data table with hundreds of cells + triggers just one reflow during the measurement phase. +
+ +Here's the implementation structure from the core package:
+ +
+ {`function extractElementInfo(element: Element, parentRect: DOMRect): ElementInfo[] {
+ const leafElements: LeafElement[] = [];
+ const wrappedCells: WrappedCell[] = [];
+
+ // Phase 1: Collect leaf elements and wrap table cells (writes only)
+ collectLeafElements(element, leafElements, wrappedCells);
+
+ // Phase 2: Measure all elements (reads only - triggers one reflow)
+ const elements = measureElements(leafElements, wrappedCells, parentRect);
+
+ // Phase 3: Clean up temporary wrappers (writes only)
+ cleanupWrappedCells(wrappedCells);
+
+ return elements;
+}
+
+function collectLeafElements(
+ element: Element,
+ leafElements: LeafElement[],
+ wrappedCells: WrappedCell[]
+): void {
+ // ... traverse DOM tree ...
+
+ const isTableCell = tag === 'td' || tag === 'th';
+ if (isTableCell && hasOnlyTextContent(element)) {
+ // Wrap text in span for measurement
+ const span = document.createElement('span');
+ span.style.display = 'inline';
+
+ while (element.firstChild) {
+ span.appendChild(element.firstChild);
+ }
+ element.appendChild(span);
+
+ // Store reference for Phase 2 measurement
+ wrappedCells.push({ element, span, borderRadius });
+ }
+}
+
+function measureElements(
+ leafElements: LeafElement[],
+ wrappedCells: WrappedCell[],
+ parentRect: DOMRect
+): ElementInfo[] {
+ const elements: ElementInfo[] = [];
+
+ // Measure regular leaf elements
+ leafElements.forEach(({ element, borderRadius }) => {
+ const rect = element.getBoundingClientRect();
+ // ... store measurements ...
+ });
+
+ // Measure wrapped table cells
+ wrappedCells.forEach(({ span, borderRadius }) => {
+ const rect = span.getBoundingClientRect();
+ // ... store measurements ...
+ });
+
+ return elements;
+}
+
+function cleanupWrappedCells(wrappedCells: WrappedCell[]): void {
+ wrappedCells.forEach(({ element, span }) => {
+ // Restore original text nodes
+ while (span.firstChild) {
+ element.insertBefore(span.firstChild, span);
+ }
+ element.removeChild(span);
+ });
+}`}
+
+
+ + The key insight is that batching DOM operations by type (all writes, then + all reads, then all cleanup) prevents layout thrashing. The browser only needs to + recalculate layout once - at the start of Phase 2, after all span wrappers have been + created. All subsequent measurements in Phase 2 use the cached layout, and the cleanup in + Phase 3 happens after measurements are complete, so it doesn't affect the captured + dimensions. +
+ +
+ This optimization is implemented in the extractElementInfo function in the
+ core package, which means all framework adapters (React, Vue, Svelte, Angular, SolidJS)
+ automatically benefit from this performance improvement. When the optimization was added,
+ no adapter-specific changes were needed - the improved performance appeared across all
+ frameworks immediately.
+
+ The table cell edge case demonstrates a broader principle:{' '} + + performance optimizations in DOM manipulation often come from careful batching of + operations + + . By separating writes from reads and processing elements in batches rather than + sequentially, we can achieve dramatic performance improvements - in this case, reducing + dozens of potential reflows down to just one. +
+