Issue while using transitions + opacity change + overflow hidden
Actually it is the border-radius
that is not getting respected when the transition is happening. This is because of creation of compositing layers for accelerated rendering and can be explained by having a look at the following articles:
- HTML5 Rocks - Accelerated Rendering in Chrome
- GPU Accelerated Compositing in Chrome.
Why does the issue not happen when transition is disabled?
- When styles change but none of the criteria that necessitates the creation of a compositing layer is satisfied (that is, no animation or transition or 3D transform etc):
- There is no compositing layer and so the whole area seems to get repainted at every change. Since a full repaint happens there is no issue.
- View the below snippet (in full screen mode) after enabling "Show paint rects" and "Show composited layer borders" from Dev tools and observe the following:
- No areas with an orange border (compositing layer) are created.
- Every time the styles are modified by setting the focus on one of the
a
tags, the whole area gets repainted (a red or green blinking area).
.outer {
position: relative;
height: 100px;
width: 100px;
margin-top: 50px;
border: 1px solid red;
overflow: hidden;
}
.border-radius {
border-radius: 50px;
}
.inner {
width: 50px;
height: 50px;
background-color: gray;
opacity: 0.75;
}
a:focus + .outer.border-radius > .inner {
transform: translateX(50px);
height: 51px;
opacity: 0.5;
}
<a href='#'>Test</a>
<div class='outer border-radius'>
<div class='inner'>I am a strange root.
</div>
</div>
Why does adding a transition create a problem?
- Initial rendering has no compositing layer because there is no transition yet on the element. View the below snippet and note how when the snippet is run a paint (red or green blinking area) happens but no compositing layer (area with orange border) is created.
- When transition starts, Chrome splits them into different compositing layers when some properties like opacity, transform etc are being transitioned. Notice how two areas with orange borders are displayed as soon as the focus is set on one of the anchor tags. These are the compositing layers that got created.
- The layer splitting is happening for accelerated rendering. As mentioned in the HTML5 Rocks article, the opacity and transform changes are applied by changing the attributes of the compositing layer and no repainting occurs.
- At the end of the transition, a repaint happens to merge all the layers back into a single layer because compositing layers are no longer applicable (based on criteria for creation of layers).
.outer {
position: relative;
height: 100px;
width: 100px;
margin-top: 50px;
border: 1px solid red;
overflow: hidden;
}
.border-radius {
border-radius: 50px;
}
.inner {
width: 50px;
height: 50px;
background-color: gray;
transition: all 1s 5s;
/*transition: height 1s 5s; /* uncomment this to see how other properties don't create a compositing layer */
opacity: 0.75;
}
a:focus + .outer.border-radius > .inner {
transform: translateX(50px);
opacity: 0.5;
/*height: 60px; */
}
<a href='#'>Test</a>
<div class='outer border-radius'>
<div class='inner'>I am a strange root.
</div>
</div>
This illustrates that when the layers are merged back and full repaint happens, the border-radius
on the parent also gets applied and respected. However, during transition only the compositing layer's properties are changed, so the layer seems to become unaware of the properties of other layers and thus doesn't respect the border-radius of the parent.
I would assume this to be because of the way rendering of layers work. Each layer is a software bitmap and so it kind of becomes equivalent to having a circular image and then placing a div
on top of it. That would obviously not result in any clipping of content.
The comment in this bug thread also seems to confirm that a repaint happens when a separate layer is no longer required.
We want to repaint if "gets own layer" is going to change
Note: Though they are Chrome specific, I think the behavior should be similar in others also.
What is the solution?
The solution seems to be to create a separate stacking context for the parent (.qs-timer
) element. Creating a separate stacking context seems to result in a separate compositing layer being created for the parent and this solves the issue.
As mentioned by BoltClock in this answer, any one of the following options would create a separate stacking context for the parent and doing one of them seems to resolve the issue.
-
Setting a
z-index
on the parent.qs-timer
to anything other than auto.var timer = setInterval(function() { document.querySelector(".qs-timer-overlay").style.opacity = (document.querySelector(".qs-timer-overlay").style.opacity * 1) + 0.1; if (document.querySelector(".qs-timer-overlay").style.opacity * 1 == 1) { clearInterval(timer); } }, 1000);
.qs-main-header .qs-timer { padding: 13px 10px; min-width: 130px; text-align: center; display: inline-block; background-color: #dd8b3a; color: #FFF; font-size: 20px; border-radius: 50px; text-transform: uppercase; float: right; cursor: pointer; position: relative; overflow: hidden; z-index: 1; /* creates a separate stacking context */ } .qs-main-header .qs-timer-overlay { z-index: 1; width: 10%; max-width: 100%; position: absolute; height: 100%; top: 0; left: 0; background-color: #c7543e; opacity: 0.0; /* border-radius: 50px 50px 0px 50px; */ } .qs-main-header .qs-timer-content { z-index: 2; position: relative; } .scale-transition { -webkit-transition: all 1s; transition: all 1s; }
<div class="qs-main-header"> <div class="qs-timer scale-transition ng-hide" ng-show="visibility.timer"> <div class="scale-transition qs-timer-overlay"></div> <div class="qs-timer-content ng-binding">0 <span class="ng-binding">Sec(s)</span> </div> </div> </div>
-
Setting
opacity
to anything less than 1. I have used 0.99 in the below snippet as it doesn't cause any visual difference.var timer = setInterval(function() { document.querySelector(".qs-timer-overlay").style.opacity = (document.querySelector(".qs-timer-overlay").style.opacity * 1) + 0.1; if (document.querySelector(".qs-timer-overlay").style.opacity * 1 == 1) { clearInterval(timer); } }, 1000);
.qs-main-header .qs-timer { padding: 13px 10px; min-width: 130px; text-align: center; display: inline-block; background-color: #dd8b3a; color: #FFF; font-size: 20px; border-radius: 50px; text-transform: uppercase; float: right; cursor: pointer; position: relative; overflow: hidden; opacity: 0.99; /* creates a separate stacking context */ } .qs-main-header .qs-timer-overlay { z-index: 1; width: 10%; max-width: 100%; position: absolute; height: 100%; top: 0; left: 0; background-color: #c7543e; opacity: 0.0; /* border-radius: 50px 50px 0px 50px; */ } .qs-main-header .qs-timer-content { z-index: 2; position: relative; } .scale-transition { -webkit-transition: all 1s; transition: all 1s; }
<div class="qs-main-header"> <div class="qs-timer scale-transition ng-hide" ng-show="visibility.timer"> <div class="scale-transition qs-timer-overlay"></div> <div class="qs-timer-content ng-binding">0 <span class="ng-binding">Sec(s)</span> </div> </div> </div>
-
Adding a
transform
to the element. I have usedtranslateZ(0px)
in the below snippet as this also doesn't create any visual difference.var timer = setInterval(function() { document.querySelector(".qs-timer-overlay").style.opacity = (document.querySelector(".qs-timer-overlay").style.opacity * 1) + 0.1; if (document.querySelector(".qs-timer-overlay").style.opacity * 1 == 1) { clearInterval(timer); } }, 1000);
.qs-main-header .qs-timer { padding: 13px 10px; min-width: 130px; text-align: center; display: inline-block; background-color: #dd8b3a; color: #FFF; font-size: 20px; border-radius: 50px; text-transform: uppercase; float: right; cursor: pointer; position: relative; overflow: hidden; transform: translateZ(0px) /* creates a separate stacking context */ } .qs-main-header .qs-timer-overlay { z-index: 1; width: 10%; max-width: 100%; position: absolute; height: 100%; top: 0; left: 0; background-color: #c7543e; opacity: 0.0; /* border-radius: 50px 50px 0px 50px; */ } .qs-main-header .qs-timer-content { z-index: 2; position: relative; } .scale-transition { -webkit-transition: all 1s; transition: all 1s; }
<div class="qs-main-header"> <div class="qs-timer scale-transition ng-hide" ng-show="visibility.timer"> <div class="scale-transition qs-timer-overlay"></div> <div class="qs-timer-content ng-binding">0 <span class="ng-binding">Sec(s)</span> </div> </div> </div>
The first two approaches are more preferable than the third because the third one works only on a browser that supports CSS transforms.
Yes, adding opacity: 0.99;
to .qs-timer
issue will fixed.
When opacity: 1 OR NOT define:
In this special case, there is no transparency involved so that gfx
could avoid doing the expensive things.
In case Opacity: 0.99:nsIFrame::HasOpacity()
decides that there is an opacity, so gfx
include valuable things. ( likes opacity
with border-radius
)
For more help Special case opacity:0.99 to treat it as opacity:1 for graphics , This ticket is not providing the opinion of our actual goal, but giving the idea about what is happening inside of CSS.