Split Effects with no Content Duplication
Split Effects with no Content Duplication êŽë š
Arecent posthere lead me to another calledThe Magic of Clip Pathby Emil Kowalski, which focuses on theinset()
basic shape in particular. While I agree thatclip-path
is a very useful property and theinset()
basic shape is underrated and underused on the web, most of the use case examples in the article are far from ideal as they rely on content duplication, which can come at a maintenance, performance, and accessibility cost, not to mention that some of them break in some scenarios.
In this article, Iâll be showing how to get the same effects with no content duplication.
Comparison Sliders
Emil creates this with two different images (the before and the after) stacked one on top of the other, the top one being clipped, plus abutton
for the draggable line control.
Using abutton
for the draggable line somehow doesnât feel right to me, but Iâm no expert when it comes to accessibility, so weâll be focusing on how to do this with a single image, though this is the one example where I can see some advantages to using two images instead of one.
This kind of comparison slider is something I once explained in detail inanother articlesome years back. The basic idea used there is the following: the original image (the âbeforeâ) is abackground
layer of a slider whose thumb is the draggable split line. The original imagebackground
layer is blended with another which only covers the progress area between the lateral edge of the track and the current thumb position. The result of the blending operation is the âafterâ.
In my old article, the result of the blending operation was the negative of the image:
Hereâs a fancier version of it:
But we can also have other effects, for example desaturating an image (black and white photography effect).
This demo needs a single HTML element (input[type=range]
), less than 20 CSS declarations (and thatâs with having to duplicate a bunch of them for the-webkit-
and-moz-
cases) and under100
bytes of JS (without even bothering to minify it).
The trick here is to use acolor
blend mode, which takes the the hue and the saturation of the top layer (transparent before the thumb andanygrey after) and the luminosity (which is notthe âLâ in HSL thebabydino
, that one stands for lightness, but still close enough in a lot of cases) of the bottom layer (the image).
The saturation ofanygrey is0
and zero saturation makes the hue irrelevant (if you think aboutthe HSL bicone (thebabydino
), the saturation is the horizontal distance from the vertical axis, so the greys are on the vertical axis, where the rotation around it, which gives us the hue,doesnât matter anymore (thebabydino
)).
That is, thecolor
blend mode with any grey top layer zeroes the saturation of the result, giving us a fully desaturated image (as if we appliedfilter: grayscale(1)
on it).
We can also make a fully desaturated image monochrome. This only requires a couple of tiny changes from the previous demo: replacing the grey with a dark blue (or anything with non-zero saturation, really) and using a black and white image.
Or why just monochrome it when we can duotone it?
Here, we just switched to anexclusion
blend mode. How this works in the back is something I explained in a lot of detail in the blend modes article.
This technique also has the advantage of only needing a minimal amount of JS. All the JS does here is to update one custom property--val
when the slider thumb gets dragged. Thatâs it!
However, the amount of effects we can achieve by blending is limited and, since I wrote that article, Iâve changed my mind about the image as abackground
approach and I donât like it as much anymore nowadays. Iâd rather have an actualimg
element there, which can get a proper right click menu and a properalt
text (even though the sliders in the previous examples have a label for screen readers which explains the changing image effect on dragging the thumb).
So the more flexible and overall better approach Iâd go for nowadays involves an img
element and an input[type=range]
inside a wrapper kind of structure. On the CSS side, Iâd use a backdrop-filter
(which opens the door to endless possibilities) instead of blending. The JS remains the same, we only need it to update the same custom property as before.
The trick is to make the wrapper agrid
container with a single grid cell, stack and stretch inside this cell theimg
, then a wrapper pseudo-element and finally theinput[type=range]
on top of both.
Both the pseudo-element and the slider getpointer-events: none
, so that right click brings up a menu that allows us to open the image in a new tab, save it, copy it and so on. Note that this needs to get reverted on the sliderâsthumb
(by settingpointer-events: auto
) so we can drag it. The pseudo-element gets clipped to just the area between the sliderâs edge and the thumbâs vertical midline. It also has abackdrop-filter
. This creates the desired effect on the part of the image underneath the area this pseudo-element is clipped to.
The demo above shows a blur effect, but we have unlimited possibilities here, as we can also chain CSS filters or even use SVG ones. We can make our image grainy, introducechromatic aberrationorswap two channels⊠the skyâs the limit!
One caveat though: as cool as in the browser imagefilter
effects are, we only have access to the original image via the right click menu. We cannot save or copy the result we get after applying thefilter
this way. For the situation when we want that, stacking the filtered and clipped version of the image on top of the original is probably the better idea, even if we have to load two images.
Split Text
Emil showcases an example similar to the one used by Vercel here (raunofreiberg
). This duplicates the text and clips the version on top. It also breaks on small viewports.
But we can do something like this with no text duplication whatsoever, which also allows us to avoid such problems, regardless of the viewport size.
This can be seen in the demo below where you can drag the separator line:
The trick here is to put the text fill, the text stroke and the progress area each on one RGB channel. In my demo, the text fill uses the blue channel, the text stroke uses the red channel and the progress area uses the green channel. Note that the progress area is created using afull coverage pseudo (anatudor
)on the element containing the text and that this pseudo is blended with its parent.
p {
position: relative;
color: #00f;
-webkit-text-stroke: #f00 4px;
isolation: isolate;
&::after {
position: absolute;
inset: 0;
background: linear-gradient(#0f0 var(--prc), #000 0);
mix-blend-mode: lighten;
pointer-events: none;
}
}
The--prc
stop position is the progress value of the slider in%
. The higher up we pull it, the lower the value and the other way around.
Theisolation
property ensures the pseudo is only blended with its parent, but not with whatever backdrop may be behind its parent as well.pointer-events: none
on the pseudo ensures we can click and select the text underneath.
The result so far looks like this:

You can see that here, each of the three RGB channels are either zeroed or maxed out (0 or1). The values for the red channel (text stroke) and the blue channel (text fill) are mutually exclusive, itâs just the green channel (progress area) that can mix with the other two.
We then apply an SVGfilter
. Here, we combine two concepts Iâve talked about this year before: usingRGB channels as alpha masksand painting the graphic we extract usingan RGB value(using one of the two ways I did forthese monojicons (thebabydino
)).
Ourfilter
extracts the intersection between the blue channel (the text fill) and the green channel (the progress area) â that is, the text fill within the limits of the progress area â and paints itwhite
.

The intersection between the text fill and the progress area, painted in white.
This is pretty much like creating an alpha mask that makes opaque the area where both the blue channel and the green channel are maxed out. And at the same time, makes transparent the area where at least one of the two is0
.
Thefilter
also extracts the difference between the red channel (the text stroke) and the green channel (the progress area) â that is, the text stroke outside the progress area, then paints it using a variable (which can be eithercurrentColor
or a custom property,var(--c-neon)
, for example).

The difference between the text stroke and the progress area, painted in neon blue.
Finally, we put these two together and we have the result!
There are some other minor polishing tweaks in the demo, but this is the main idea. Itâs very similar to other text split demos with no duplication Iâve made (as seen inthis CodePen collection), the only difference being that in this case the split line isnât fixed, but depends on a value that changes when dragging the slider.

The CodePen collection.
Tabs Transition
This is another effect that Emil achieves by duplicating the whole navigation content.
As you might suspect, we can do without duplication!
What is going on here?
First off, eachnav
item has an index--i
, whereas thenav
itself has a current index--k
, equal to the index--i
of the currently selected item. The only JS necessary here is to update the value of--k
on thenav
to match the value--i
of the item weâve just clicked/selected (works withTab + Entertoo).
We want the currently selected item to have a highlight â that is, we want to have a highlight over the item where the difference between--i
and--k
is0
. In order to know from which direction this highlight needs to move when we change the selected item and--k
changes value, we need to also get the sign of this difference between--i
and--k
. Since no matter which item is selected, both--i
and--k
are setto integer values, we use this formula that I explained in detail inn older articleto compute the sign:
--sgn: clamp(-1, var(--i) - var(--k), 1)
Now, you may be wondering why in the world still use this when we now finally have thesign()
function supported cross-browser (almost, itâs still behind a flag in Chrome) and the answer is that⊠well, weâre calling it âsignâ, but thatâs not exactly what we want. I said above that both--i
and--k
are setto integer values and, while that is true,--k
also takes non-integer values when it smoothly transitions from the integer value it previously had to the one itâs currently set to.
Thesign()
function jumps from-1 to0, then to1 and we donât want that jump.We donât want this:

sign(x)
has discontinuity at 0.sign(x)
graph
We want this:

clamp(-1, x, 1)
is continuous.clamp(-1, x, 1)
graph
Next we want to know if the highlight is outside of an item or over that item, meaning that item is selected. The highlight is outside of an item if--sgn
is non-zero:
--out: abs(var(--sgn))
That is, if--sgn
is either-1
or1
, then--out
is1
, the highlight is outside the item, meaning the item is not selected. If--sgn
is0
, then--out
is1
, the highlight is not outside the item, but instead is over it, the item is selected.
Note that withsign()
andabs()
being the final two mathematical functionsstill behind the Experimental Web Platform features flagin Chrome, we need to wrap the above in a @supports
and also use a fallback:
--out: max(var(--sgn), -1*var(--sgn));
Thereâs still one more thing we need to compute here and thatâs whether we need to move the highlight towards the positive direction of thexaxis if we were to select an item of index--i
thatâs currently not selected:
--bit: round(.5*(1 + var(--sgn)))
Weâd need to move in the positive direction of thexaxis if we were to select an item of an index--i
bigger than--k
, that is, if--sgn
is1
. In this case,--bit
computes to1
. Othwerwise, if we wouldnât need to move in the positive direction of thexaxis but in the negative one, then that means--i
is smaller than--k
, which means--sgn
is-1
, so--bit
computes to0
.
We create the highlight with a pseudo-element, which is absolutely positioned tocover its entire parent. Okay, but we want this highlight only for the currently selected item, so we clip it to nothing for all the others, that is, for all those where--out
computes to1
.
And we know whether to clip it from the right or from the left based on the--bit
value. If--bit
is1
, we clip from the right (the positive direction of thexaxis). Otherwise, we clip from the left. This is theclip-path
and the lateral offset values:
--r: calc(var(--out)*var(--bit)*100%);
--l: calc(var(--out)*(1 - var(--bit))*100%);
clip-path: inset(0 var(--r) 0 var(--l));
The final ingredient is toregister(as'<number>'
) and transition--k
.
You can see a basic version of this below:
Now you may see a teeny tiny gap in the highlight during thetransition
.

The problem.
This is due to pixel rounding and the way to fix it is to ensure the pseudo-element highlight doesnât have a width that might get roundeddownto an integer number of pixels.
inset: 0 -.5px
Thatâs it!
Okay, but what about the rounded corners and the textcolor
change on intersecting the highlight? For that, we need an SVGfilter
that achieves two things. One,the blobby lookand two, something very similar to the one in the earlier text split example, a different look for the text where it intersects the highlight blob versus where it doesnât. We also want to have a different look for the:hover
/:focus
state outside the blob.
Just like in the text split case, we use a separate RGB channel for each component. The red channel is used for the highlight, the blue channel for the regular text and the green channel for the text in the :hover
/:focus
case.

Then in the SVGfilter
, we first extract the highlight out of the red channel, paint it blue and turn its shape into a blob. Then we extract the text out of the blue and green channels and paint it either grey or blue depending on what channel itâs on. We place the blob on top of it and on top of that, we put the intersection between the text and the blob, painted in white.
Theme Switch Transition
Emil shared a version that duplicates the entire page, one version being in light mode and the other in the dark mode, with the top one having a clip-path
on it.
Back when:has()
was still a new feature in late 2022, I started toying with a bunch theme switch effects using it and one of them produced a result very similar to this, but without duplicating any content.
Letâs take a quick look at the idea behind!
Itâs not very far from what Iâm doing in this bubble theme switch (which Iâve explained in detail in the Pen description), but it allows for more control than simply inverting whatâs on the page.
We have a custom property--dark
thatâs0
in the light theme case and1
in the dark theme case.
body {
--dark: 0;
&:has(#dark:checked) { --dark: 1 }
}
We make the pagebackground
a CSS gradient withbackground-attachment: fixed
and depending on the--dark
custom property via a percentage--perc
, which we register as'<length-percentage>'
so it can be transitioned when we switch the theme.
body {
/* same as before */
--perc: calc(var(--dark)*100%);
background:
linear-gradient(90deg, #333 var(--perc), #ddd 0) fixed;
transition: --perc .65s
}
We do something similar for the text itself. In this case, we also need to set thecolor
property totransparent
and clip itsbackground
totext
. This isnât super ideal as we can end up having to set such abackground
clipped totext
to a lot of elements on the page, but oh, wellâŠ
p, label {
background:
linear-gradient(90deg, #ddd var(--perc), #333 0) text fixed
}
Click on either âdarkâ or âlightâ below:
This is the basic idea behind. There are two big issues with it.
One, the swipe direction changes (it goes from right to left) when we switch back from the dark theme to the light one. We want the swipe transition to always go from left to right. We can fix this by using an angle--ang
that depends on the value of the--dark
switch. This isnât the best solution as it limits us to a linear swipe effect, but weâll stick to it for now and come back to this problem later.
--sign: sign(var(--dark) - .5);
--ang: calc(var(--sign)*90deg);
To this angle --ang
, we may add another one that gives us the direction of the swipe.
Two, it doesnât give any indication about whether any of these controls is focused or hovered (for example an outline) and there are no special styles for the currently selected one, but we can fix that using theDRY switching technique(with--hov
and--sel
switches) pluscolor-mix()
to further simplify things.
The same idea applies to all text and links.
There are a couple of issues here.
The first is that we have these ugly edges around the link letters due to the fact that the linkbackground
clipped totext
is placed on top of the paragraphbackground
clipped to the sametext
and the latter contrasts with the pagebackground
even more.
There are a couple of pretty straightforward fixes here. One by isolating the paragraph and then applying ahard-light
blend mode on the link and another by using slightly thicker text for the link. For example, with a font likeREM, we can give the normal paragraph text afont-weight
of300
and the links afont-weight
of400
.
The second is that when thebackground
gets clipped totext
, that doesnât also include thetext-decoration
(underline, for example).
An easy solution for this would normally be to add anotherbackground
of limited vertical size at thebottom
, but this doesnât work here due to thefixed
nature of thebackground
.
So weâre forced to use a pseudo and make linksinline-block
or wrap each linkâs text content in aspan
. Each of these comes with some complications of its own, but oh, wellâŠ
In the future, being able to clip thefixed
background to textandto a bottom border should do the trick without the need for the extra pseudo-element hack (see thisproposalby Lea Verou).
Also, for every element that needs to have both text content and abackground
(like abutton
, for example!)⊠some bad news! Because of aFirefox bugold enough to go to school, we need to either make that elementinline-block
like we did in the links scenario and use a pseudo or wrap that text content in an innerspan
. Or, in order to avoid the problems that these two methods come with (and maybe introduce some performance ones instead), we could use an SVGfilter
. Thatâs pretty much what we have to do for a lot ofinput
elements (likeinput[type=button]
) anyway.
Okay, but what if we want to have a patternedbackground
? Or what if we want a more interesting link hover effect, for example aXOR one? Or other kinds of XOR effects, for example one on a header? Blending (thedifference
blend mode in particular) to the rescue!
What about having some gradients on the page? For example, in the case of gradient buttons. That can be done using the same trick of putting the text, the gradient of thebutton
and the gradient determining the progress of the theme swipe transition each on a different RGB channel. Then we use an SVGfilter
to extract the gradients and text for each of the two themes, paint them as desired and resolve how much of each is shown based on the progress of the theme swipe transition.
You may remember I said something about being tied to linear swipes here. But we can fix that in order to also have radial or conic ones!
First, we need to have two swipe percentage values: one that changes instantly when selecting another theme and another one that transitions smoothly. We only register the second one (--perc-ani
).
--dark: 0;
--perc-fix: calc(var(--dark)*100%);
--perc-ani: calc(var(--dark)*100%);
We also compute a direction or sign value, whatever you want to call it. This is1
when weâve switched from the light theme to the dark one (--dark
is1
) and-1
otherwise (--dark
got switched to0
).
--sign: sign(var(--dark) - .5)
Then we compute the progress of the swipe. This always smoothly goes from0%
to100%
over the course of the swipe that changes the theme, regardless of whether weâre switching from the light to the dark theme (--sign
is1
,--perc-fix
has switched to100%
and--perc-ani
transitions from0%
to100%
) or from the dark to the light theme (--sign
is-1
,--perc-fix
has switched to0%
and--perc-ani
transitions from100%
to0%
).
--perc: calc(100% - var(--perc-fix) + var(--sign)*var(--perc-ani));
We then use this in the stop list for the gradient (which now can be a radial or a conic one too) transitioned to create the swipe effect:
--list: var(--c1) var(--perc), var(--c0) 0%
The--c0
and--c1
values depend on whether weâre going from the light theme to the dark one (--perc-fix
has switched to100%
) or the other way (--perc-fix
has switched to0%
):
--c0: color-mix(in srgb, var(--dark) var(--perc-fix), var(--light));
--c1: color-mix(in srgb, var(--dark), var(--light) var(--perc-fix));
And thatâs it! This is the technique from my initial demo in this section which allows us to use any kind of gradient for our swipe.
I hope youâve enjoyed this little ride through the land of fun effects without content duplication!