Next.js Discord

Discord Forum

Avoiding layout shift

Answered
Cape lion posted this in #help-forum
Open in Discord
Cape lionOP
I have a client component called InvisibleImageViewer which is causing layout shifts in the website. The content inside the viewer component is completely SSR compatible however I can't put it outside the InvisibleImageViewer component because I need a reference to the parent element (in this case the Bordered component) or a wrapping div around it so that I can attach an event listener to it. The reason why I need such a reference is so that I can achieve an animation which requires the knowledge of the location of an element to work.

I feel a lot more of this can be server rendered and some invisible change can be done by a client component to attach event listeners to existing components in the DOM, but I don't know how I can achieve that. If this can be done then the CLS problem would be gone. Perhaps I need to re-think about how to implement the viewer to more easily support that.
Answered by Cape lion
I just realized the issue here
View full answer

164 Replies

Cape lionOP
A solution which will work here is to perhaps have a completely invisible container take up the exact same space as the other, however this feels kinda like a last resort solution because it would kinda double the work when it comes to the CSS layouting stuff to make sure both the invisible and real layout are identical
Cape lionOP
bump
Cape lionOP
bump again, if my question lacks information or is unclear please let me know, I can give more details or try re-explaining the issue
Golden-winged Warbler
I don't recall where I saw the rationale for this, but as an example, the <Image> component requires you set the dimensions so that layout shifts are avoided. In other words, you have to tell it the size ahead of time so it can appropriately layout before getting the actual image. I would assume you likely need to do something similar

https://nextjs.org/docs/pages/api-reference/components/image
Golden-winged Warbler
for example, you can say a div is width: 100% and aspect-ratio: 16/9 and it would automatically scale up to the size necessary
and within that div you can have, for example, an Image tag which just fills it
which means the size of the Image would have no effect on the layout, hence no shift when the image loads in
Golden-winged Warbler
They explicitly call out "fill" which probably addresses this, afaik
Asian black bear
@Cape lion A pretty common paradigm to avoid this is to have an outer component with at least a fixed aspect ratio, and the image absolutely positioned inside to the full space. object-fit can help if the AR of the image is a flexible
You can position a next/image as such using the fill property https://nextjs.org/docs/pages/api-reference/components/image#fill
as seen in thi video, when the component loads in there's a layout shift
@Cape lion A solution which will work here is to perhaps have a completely invisible container take up the exact same space as the other, however this feels kinda like a last resort solution because it would kinda double the work when it comes to the CSS layouting stuff to make sure both the invisible and real layout are identical
Cape lionOP
this solution would work, so basically
<div className="relative">
  <div>
    // copy layout of the component below but keep it invisible
  </div>
  <div className="absolute top-0 left-0">
    <Viewer/>
  </div>
</div>

but this seems very annoying
Asian black bear
Obviously the only way to prevent that layout shift would be to leave an empty space there
@Asian black bear Obviously the only way to prevent that layout shift would be to leave an empty space there
Cape lionOP
hmm dang, the reason why this solution feels weird is because the content inside the component is fully SSR compatible, but because it's nested inside a client component there's this ugly layout shift
Asian black bear
Why is your nested client component not doing SSR?
@Asian black bear Why is your nested client component not doing SSR?
Cape lionOP
I checked the network requests, the inner component only shows up when the Viewer_.tsx thingy gets fetched
Asian black bear
really the only way it could be blocking it is something with useEffect
@Asian black bear really the only way it could be blocking it is something with `useEffect`
Cape lionOP
there are definitely many useEffects for the Viewer component
like your suggestion from earlier I switched the viewer from being a global thingy to an individual image based thing
Asian black bear
hell yeah
I like that the vercel bot will never know about what that is
Asian black bear
yeah it looks awesome
Anyways, for the layout shift, usually the most idiomatic way is for the component itself to reserve space using some kind of base loading state.
@Cape lion there are definitely many `useEffect`s for the `Viewer` component
Cape lionOP
the way I achieve that effect is that I have an empty div inside the red border and then using createPortal (which you also suggested) I'm making an image on the body level which is placed exactly on top of that empty div, for this to work I need a reference to that empty div so that I can copy the x, y, width and height
Asian black bear
ohhhh that seems very complicated
@Asian black bear Anyways, for the layout shift, usually the most idiomatic way is for the component itself to reserve space using some kind of base loading state.
Cape lionOP
ideally for me this would just be the border along with that empty div in it, the image can get added later
@Asian black bear ohhhh that seems very complicated
Cape lionOP
yeah, but I can't think of a simpler way
Asian black bear
yeah you can 😉
Cape lionOP
doing it the way I did above allows me to use an animation library (framer motion) to animate it back and forth
Asian black bear
make each image it's own component that controls its own fixed popout state
@Asian black bear make each image it's own component that controls its own fixed popout state
Cape lionOP
so like a conditional createPortal?
Asian black bear
I don't think you should really need a portal here
it is just kind of over-engineered
@Asian black bear I don't think you should *really* need a portal here
Cape lionOP
how else can I pop it out of the layout when centering the image on the screen?
Asian black bear
className="fixed left-1/2 top-1/2 -translate-y-1/2 -translate-x-1/2"
I wonder if the animation library can go from normal to fixed layout like that
but that does make sense, and is way simpler
Asian black bear
can you just use CSS animations?
@Asian black bear can you just use CSS animations?
Cape lionOP
I suppose for this one animation I could
but would the switch from whatever position: to position: fixed animate properly?
if it theoretically doesn't, then there's still the problem of having to find the coordinates of the image but now in fixed space
Asian black bear
This library seems extremely confident it can do what you want: https://www.framer.com/motion/layout-animations/
Cape lionOP
that's fair
Asian black bear
it can animate other crazy stuff that makes no sense too
Cape lionOP
hmm okay, this would simplify things a lot
would it be like a temporary loading state which can be done with SSR?
if so that would be perfect
Asian black bear
It is probably already kind of there in your code. Somewhere before the whole thing loads you are returning an empty something
be it null, or <div/>, or ...
@Asian black bear It is probably already kind of there in your code. Somewhere before the whole thing loads you are returning an empty something
Cape lionOP
nope
this is what Viewer returns
the border and empty div would be the children
Asian black bear
{initialized ? content(...) : <div className="make-this-box-have-a-size"></div>}
@Asian black bear `{initialized ? content(...) : <div className="make-this-box-have-a-size"></div>}`
Cape lionOP
this shouldn't be causing the layout shifts because this is inside the portal
it's inside a div which is absolutely positioned as well
Asian black bear
portals don't have size
I don't see motion.div getting a size from anywhere in the provided sinippet
document.body isn't clamping it down, and actually the children are a fragment with two separate elements
the top fixed div does not contain the bottom motion.div
@Asian black bear portals don't have size
Cape lionOP
yeah but in this case, it isn't effecting the layout afaik
here I removed the portal entirely, now it's just left with the border and the empty div
@Asian black bear I don't see motion.div getting a size from anywhere in the provided sinippet
Cape lionOP
the size for it is being set here
where imageRef is a reference to the empty div
Asian black bear
Do yourself a favor, and try to write up a quick little snippet that uses framer motion to animate an image directly in that square gaining a fixed style
I think you will be shocked by how little code it requires
Cape lionOP
yeah true
but that won't solve the layout shift problem right?
@Cape lion Click to see attachment
Asian black bear
This feels like an anti-pattern
@Asian black bear This feels like an anti-pattern
Cape lionOP
oh it definitely felt like one when I was writing it
it's a very ugly solution
I had to add a mutation observer so that the images keep working on resizing for example
Asian black bear
brutal
So, if you have the document SSR with the image in the normal flow, especially using the fixed aspect ratio container trick, that will cause zero layout shift
like on PageSpeed layout shift is zero
when transitioning to the popped-out state, you need to position the image either by positioning it outside of the fixed aspect container while leaving it behind to take document space. OR, you can just make an exact copy of the fixed AR container (without image) and leave it behind while positioning the original one
Maybe add a keyboard listener to close it if someone presses escape, an aria dialog role, and (maybe) an invisible close button for screen readers too
Cape lionOP
hmm
this is all I have now in the Viewer component
Asian black bear
the more I look at this, the harder it seems that you will avoid the layout shift
the client rectangle stuff is not available in SSR
you really would need an entirely separate "target" box with fixed size that renders in the document flow
@Asian black bear the client rectangle stuff is not available in SSR
Cape lionOP
the thing is if I just copy and paste that border stuff directly into the SSR'ed page it works without any layout shift
(oh and ImageViewer is also another client component which wraps the Viewer component, because the Viewer component is a very generalized thingy)
@Cape lion the thing is if I just copy and paste that border stuff directly into the SSR'ed page it works without any layout shift
Cape lionOP
in the current ugly implmentation ideally this stuff here can be SSR
but the image can be added later on top of the empty div
but I have no idea if that's possible to achieve or not
Asian black bear
of couse it is possible
Cape lionOP
how though 😭
Asian black bear
I would never implement this, this way, but if I did, it would be with a ref
I would make a div having a size that I want the image to fit into, get a ref to it, and make all of that other crazy code get the bounding rectangle from the ref
probably I would use a hook too
@Asian black bear I would make a div having a size that I want the image to fit into, get a ref to it, and make all of that other crazy code get the bounding rectangle from the ref
Cape lionOP
that's actually what I already have going on
the issue is I can't make a ref in SSR code because refs are not SSR compatible it seems
so the ImageViewer component also has to be a client component
Asian black bear
um, you are confused about the difference between SSR and RSC
the idea is that you have the target div live outside of <Viewer>
@Asian black bear the idea is that you have the target div live *outside* of <Viewer>
Cape lionOP
it is in this case, all that ugly code I showed before is inside the Viewer component, the code above is the ImageViewer component
@Cape lion Click to see attachment
Cape lionOP
it is the ImageViewer I use in the page
Asian black bear
what element in there is supposed to have a fixed size?
@Asian black bear what element in there is supposed to have a fixed size?
Cape lionOP
the div with the ref on it
Asian black bear
why is that <div>, a plain HTML element, not showing up in the SSR?
@Cape lion hmm
Cape lionOP
that is the gray div drawn here
it seems to be waiting for the viewer component to be fetched to draw it
(look at the network tab)
hold on, I'll simplify this even further
Asian black bear
It is because Viewer, or Border, is waiting for something in useEffect to render it
to get into SSR, something needs to be returned from the component the very first time it runs
Cape lionOP
ah ah
Cape lionOP
I just realized the issue here
Answer
Cape lionOP
fk how did I not remember this
I was dynamically loading in the viewer
ofcourse that's why it was waiting for it
@Cape lion nope
Cape lionOP
iirc the reason for this was because for the portal I referenced document.body which isn't available in the server
so I had to fetch it dynamically so that the error would go away
@Cape lion fk how did I not remember this
Cape lionOP
yep okay, swapping it to a normal import works just as expected 🤦
Asian black bear
nice
@Cape lion iirc the reason for this was because for the portal I referenced `document.body` which isn't available in the server
Cape lionOP
is there a way to do this without doing a dynamic import?
switching to your solution should avoid this problem entirely
but just to know in the future
Asian black bear
create the portal in useEffect
@Asian black bear create the portal in useEffect
Cape lionOP
ah, ofcourse
that's galaxy brain
😭
why didn't I think of that
Asian black bear
svelte developers don't mind when things get complicated
Asian black bear
that is what I have learned in the past six months
can't deny your experience
Asian black bear
React is so unpredictable and dangerous, its most ardent users are ever fretful not to annoy or enrage it
"This even handler takes 230ms, but at least it is guaranteed to work"
anyways, thanks a lot for the help! (again)
I shall mark something something as a solution
Asian black bear
yeah!
mark the thing where you said to use my idea from chats
j/k I just saw you on here and wanted to help a friend, not for the points
Asian black bear
I burn 100% of my goodwill with Vercel by being a total annoying person on GitHub
Asian black bear
btw I learned something new, because I would have never thought a dynamic import was the problem
@Asian black bear btw I learned something new, because I would have *never* thought a dynamic import was the problem
Cape lionOP
is it because it's so obvious that you thought I would've already factored it out or because you genuinely didn't know? 😄
@Cape lion is it because it's so obvious that you thought I would've already factored it out or because you genuinely didn't know? 😄
Asian black bear
I don't really use them much, and assume nobody else does either
Cape lionOP
so awesome
just patched that one issue and the layout shift problem is gone 😍
well, it seems I have uncovered another bug though 😈
@Asian black bear I don't really use them much, and assume nobody else does either
Cape lionOP
that's very fair, now I can only think of one actual use case for dynamic imports where it was 2 before 😄
and that is for a component which uses a worker thread
where I sorta want to have one worker being shared among all the components
but yes, thank you again 🙂