Creating a Fluted Glass effect using React and Styled Components
Whenever I see an image with an effect that makes an impression on me, I cant help myself but try to recreate it using code, I know it might be time consuming and sometimes not worth the effort, but there is something about the experimentation that makes the result so much better than what you planned it to be.


Controls
This is the component we will be building throughout the article, you can play with the controls here
Inspiration
The insperation for this article came from the image on the front page of this website, the talented designer that created this image for me used a glass effect that I immediately fell in-love with.
Due to time constraints I decided to just export the image from Figma and use it as is, its actully probably for the best for performance reasons, you want your front page to load as fast as possible.
But at the same time, my fingers were itching to try and take the challenge of this effect on.
Challenges
The challenges might seem clear, but under the surface there are a few issues that need to be addressed.
Issue #1 - I do not want to the developer or designer to have to do anything but have a single image that they would like to apply the effect to, this means no 2nd image for the glass effect or the blur effect.
Issue #2 - The glass and blur effect themselves might seem easy enough, take an image and add blur(<number>px) and transform to the class, right?
well... not really.
The idea is that you have a repeating element with a skew that has the original image as a background with a calculated background-position-x for each of the repeating elements. Well, once you add a blur effect to the repeating image, the div will just... blur away, and you will not be able to the see effect at all.
A demonstration of the issue:
As we can see, the blur effect can only be applied to the entire div, and not to the background image itself.
<div class="output">
<div class="no_blur">
<div/>
<div/>
<div/>
<div/>
</div>
<div class="with_blur">
<div/>
<div/>
<div/>
<div/>
</div>
</div>
<style>
.output {
display: flex;
justify-content: space-around;
}
.output > div {
width: 45%;
aspect-ratio: 1 / 1;
display: flex;
background-color: black;
}
.output > div > div {
width: 25%;
height: 100%;
background-image: url('/images/articles/fluted_glass/polka_dot.webp');
}
.no_blur > div {
transform: perspective(200px) rotateY(12deg);
}
.with_blur > div {
filter: blur(4px);
transform: perspective(200px) rotateY(12deg);
}
</style>How do we solve this?
The solution that I chose was to generate a new image with the blur effect baked in to it, and use that as the background image for the repeating elements, without applying the blur to the elements themselves.
This method requires us to:
- Use a canvas element
- Load the image regularly (to enjoy the native browser caching mechanisms)
- Draw the image onto the canvas
- Apply the blur effect to the canvas
- Turn the canvas into a
Blob - Get the
DataURLof theBlob - Make the CSS pretty
- Create a cool animation
- etc...
The implementation
For my implementation I decided to use React and Styled Components, just because I prefer them over plain SASS styling for the ease of use and reusability that they provide (I much prefer importing a component than remembering the name of a css class).
My approach
import React, { PropsWithChildren, useMemo, useState } from 'react';
type Props = {
image: string
blurAmount?: number
} & React.HTMLAttributes<HTMLDivElement> & PropsWithChildren
const FlutedGlass = ({
image,
blurAmount,
children,
...props
}: Props) => {
const [canvasRef, useCanvasRef] = useState<HTMLCanvasElement | null>(null)
const [imageRef, useImageRef] = useState<HTMLImageElement | null>(null)
const [imageLoaded, setImageLoaded] = useState<boolean>(false)
const [blurryUrl, setBlurryUrl] = useState<string | null>(null)
const imageBoundingRect = useMemo(() => {
if (!imageRef || !imageLoaded) return
return imageRef.getBoundingClientRect()
}, [imageRef, imageLoaded])
useEffect(() => {
if (!imageRef || !canvasRef || !imageLoaded || !imageBoundingRect) return
const canvas = canvasRef
const ctx = canvas.getContext("2d");
if (!ctx) return
// Apply blur on the canvas and then draw the image (the order is important)
ctx.filter = `blur(${blurAmount}px)`;
ctx.drawImage(imageRef, 0, 0, imageBoundingRect.width, imageBoundingRect.height);
// const dataurl = canvas.toDataURL() # this is the long version
canvas.toBlob(blob => {
if (!blob) return
const imageUrl = URL.createObjectURL(blob);
setBlurryUrl(imageUrl)
})
}, [imageLoaded, imageRef, canvasRef, imageBoundingRect, blurAmount])
return (
<div {...props}>
{children}
<img onLoad={() => {setImageLoaded(true)}} ref={useImageRef} src={image}/>
{imageLoaded && (
<canvas ref={useCanvasRef} width={imageBoundingRect?.width} height={imageBoundingRect?.height}/>
)}
</div>
)
}Lets read this bottom up
In our return statement we render the children of the component, I've decided to include the children as part of the component rather than keeping this component standalone when I was trying to implement hover effects and realized its too much trouble. We then render the image, and keep the reference, which i've gotten into the habit of keeping in the state rather than as a useRef.
Once the image is loaded we render the canvas element, and keep the reference to it as well, so later we can draw the image onto it with the blur effect.
About keeping refs in state
Keeping refs in state is easier to work with but requires a bit more thought when optimizing for performance, since the usage of useState will cause a re-render of the component, and if the component is re-rendered a lot, it might cause performance issues. In this case we shouldn't be re-rendering much.
Creating a blurry image
Since we're keeping all of the references of our elements in the state, we can wait for everything to load and within a useEffect, generate our blurry image
in the first few lines we have this code:
const ctx = canvasRef.getContext("2d");
if (!ctx) return
ctx.filter = `blur(${blurAmount}px)`;
ctx.drawImage(imageRef, 0, 0, imageBoundingRect.width, imageBoundingRect.height);Here we:
- get the
2dcontext of the canvas - set the blur effect
- draw the image onto the canvas
We also depend on having the original image loaded, and find the size of it using another memo.
const imageBoundingRect = useMemo(() => {
if (!imageRef || !imageLoaded) return
return imageRef.getBoundingClientRect()
}, [imageRef, imageLoaded])Getting a minified DataURL
A DataURL is basically a string that contains an image in base64 format, it looks something like this:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA04AAAJCCAYAAAD3FZdOAAAAAXNSR0IArs4c6QAAIABJREFUeF7MvYvOLbmNLla12+3b2J7xzJyZA+SBcxIEJ0iQAEGC5E3yHnmBJHPx3R67fem9Aopi8SP5UdL6u+cgu/H3WqtKoiiK/CiWKNX9X19f/J+frk//1XV9/snrur776bo+3dd1va7r/nTpP/uU6/Ybv1/X58t+f/GU9nqz2lMGado9+TQaeC23nevq/U9PXSvPy9U2sDzjId/f9eWLUcDlEflwarGvn4OclQ8vW3nQ8tLvHf8oy07u75Q5ba+TW26LjfWqTL7HdIZd+2i7H+lH0aOOoRfnSnWo/7fs3z1st9iSNbWTzUf7y7i1tlg3d3y8owPfRlnrt1nWh2gm4TXD66Rnedpmumi0viaMjXuB1qycaNhP+fz8+bqE1l8u/fzz/PzTJ/0u1/84//40P+W6XLOyX3/Wckb39Xy7LvEDMsZfTl0UZyJ8vj57fSkvdeX659lBE6HYgNT/4r6uL15CT39/5zbdVuwb1+aflPnO9Xk4Mfkubeu16/oEsvj8SXm2/ksfpE/ST7mGtiL1v//5ur53XddfXfr5A/n85PSzz1nq0LgpPXcLQD3BMTrRQebvom9eU5H+SpsigzEOU5VMfkOGr6leYLTex5Wfqm3v7L6zmV09bGmHn8yGMqeMj25c2fWt7Z8MLpZ5uV7mqit9W93LWP+NsA+Y2vU9t/NOu7nsri1h63OZJTmztW290tH9SPvKw/7fSV92VLJuG74iD2bnSb0C6RUOrfqCPoDxmunu7CzfP7U1LIcyMXpRLt4K3p968JrXxEWJi/jN5+v6v+//dH35f13X9Y+frpf4g+GfBKTsTyobaMmnOSu9riqJ97E8GqaW0Su5vAm4m5Sz8hhQ3ZMi8oLt2HcWhCEg1........Its not really much trouble for the browser to handle one of these strings, but when you have a lot of them, like we plan to, it can get quite heavy.
The canvas object has a method called toDataURL, which returns the canvas’s contents in base64 format, but we can also use the toBlob method, which returns a blob object, and by using the URL.createObjectUrl() method we can get a URL for this blob.
The reasoning behind creating a Blob and then getting a URL for it is that the URL.createObjectUrl() function returns a url that identifies the blob in UUID format, which is much shorter than the DataURL of the canvas, and so is much better for our purposes.
So to get a minified DataURL, we are doing the following:
- Calling the toBlob method on the canvas
- Getting the blob within the callback
- Creating a URL for the blob
- Keeping the URL of the blob in the state
The code is quite simple:
canvas.toBlob(blob => {
if (!blob) return
const imageUrl = URL.createObjectURL(blob);
setBlurryUrl(imageUrl)
})Creating the glass effect
The glass effect requires us to render many elements with the same image, but with a different background-position-x for each element. This is done by creating a repeating element with a skew effect, and a background image that is the blurry image that we created earlier.
I would like all of the parameters such as the size of each pane, the size of the glass effect, the "stretch" amount, etc.. to controllable by the props of the component, So the first step is to update the Props type with the following:
type Props = {
image: string
active?: boolean
paneSize?: number
numOfPanes?: number
activeOnHover?: boolean
blurAmount?: number
paneJustify?: 'start' | 'end',
stretchPercentage?: number,
animationMs?: number,
} & React.HTMLAttributes<HTMLDivElement> & PropsWithChildrenWe also need to update the rendering of the component to use these props as well as calculate the background size according to the "stretch" amount.
/**
* Calculate the background position of each pane
* based on the pane size and the number of panes as well as the justify prop
*/
const calculatePaneBackgroundPos = useCallback((i: number) => {
if (paneJustify === 'start') {
return `${(i * singlePaneWidth) / (100 / paneSize)}%`
}
return `${(100 - paneSize) + (i * singlePaneWidth) / (100 / paneSize)}%`
}, [paneJustify, singlePaneWidth, paneSize])
/**
* The width of the background image is the number of panes * 100%
*/
const backgroundSizeWidth = useMemo(() => {
return numOfPanes * (100 - stretchPercentage)
}, [numOfPanes, stretchPercentage])
return (
<Wrapper $active={active} $hover={activeOnHover} $side={paneJustify} {...props}>
{children}
<StyledImage onLoad={() => {setImageLoaded(true)}} ref={useImageRef} src={image}/>
{imageLoaded && (
<>
<StyledCanvas ref={useCanvasRef} width={imageBoundingRect?.width} height={imageBoundingRect?.height}/>
<PanesWrapper $width={paneSize}>
{blurryUrl && Array(numOfPanes).fill(0).map((_, i) => i).map((i) => (
<Pane
style={{
backgroundImage: `url(${blurryUrl})`,
backgroundSize: `${backgroundSizeWidth}% 84%`,
backgroundPositionX: calculatePaneBackgroundPos(i)
}}
$animationMs={animationMs}
key={`pane-${i}`}
/>
))}
</PanesWrapper>
</>
)}
</Wrapper>
)Note:
Using the $ sign before the prop name is a convention of styled-components called Transient Props which was introduced in version 5.1, it allows us to pass props to the styled component without them being rendered in the DOM.
Styled Components
I like using styled components, its an easy way to write "SASS" like code and control it using props, and it also makes the code more readable in my opinion. There are some performance pitfalls with Styled Components to be aware of, but for the most part, you can use them without any issues.
In our case there are two main styled components that really matter, the Wrapper and the Pane.
Pane
The Pane styled component is the repeating element that creates the glass effect, and is also the biggest performance pitfall to avoid. Since we render the Pane many times its best to keep it as simple as possible and to attempt to avoid the creation of a new styled component for each one of the Panes which the library will do by default if we try to render the same component with different values for the props on each iteration.
To avoid this we can just pass the style attribute onto the Pane component, and then use the style attribute to style the component with the style that repeats for each one of the panes.
The code for the Pane styled component is as follows:
const Pane = styled.div<{
$animationMs: number,
}>`
background-repeat: no-repeat;
position: relative;
height: 120%;
top: -10%;
background-position-y: center;
flex: 1 0 auto;
transform: perspective(100px) rotateY(90deg);
transition-timing-function: ease-in-out, ease-out;
${({$animationMs}) => $animationMs && css`
transition: transform ${$animationMs}ms ease-in-out;
`}
`;Since the $animationMs prop is shared between all of the Panes, we can just pass it as is and the styled component will not create a new component for each Pane.
Wrapper
const Wrapper = styled.div<{
$side: Props['paneJustify'],
$hover: Props['activeOnHover'],
$active: Props['active']
}>`
display: flex;
position: relative;
overflow: hidden;
justify-content: flex-${({$side}) => $side};
${({$hover}) => $hover && css`
&:hover ${Pane} {
transform: perspective(200px) rotateY(12deg);
}
`}
${({$active}) => $active && css`
${Pane} {
transform: perspective(200px) rotateY(12deg);
}
`}
`;Since we want to be able to control wether or not the glass effect is active on hover or not, we can use the activeOnHover prop to control the hover effect, and the active prop to control the active effect.
Styled Components allows us to reference other styled components within a styled component, so we can use the Pane styled component within the Wrapper styled component to apply the hover effect to all of the Panes when the Wrapper is hovered.
Final implementation
The final code for the FlutedGlass component includes animations, hover effects, and a lot of control over the glass effect.
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 'react';
import styled, { css } from 'styled-components';
type Props = {
image: string
active?: boolean
paneSize?: number
numOfPanes?: number
activeOnHover?: boolean
blurAmount?: number
paneJustify?: 'start' | 'end',
stretchPercentage?: number,
animationMs?: number,
} & React.HTMLAttributes<HTMLDivElement> & PropsWithChildren
export const FlutedGlass = ({
image,
active = false,
paneSize = 50,
numOfPanes = 10,
paneJustify = 'start',
activeOnHover,
blurAmount,
stretchPercentage = 0,
animationMs = 50,
children,
...props
}: Props) => {
const [canvasRef, useCanvasRef] = useState<HTMLCanvasElement | null>(null)
const [imageRef, useImageRef] = useState<HTMLImageElement | null>(null)
const [imageLoaded, setImageLoaded] = useState(false)
const [blurryUrl, setBlurryUrl] = useState<string | null>(null)
const singlePaneWidth = useMemo(() => {
return 100 / numOfPanes
}, [numOfPanes])
const imageBoundingRect = useMemo(() => {
if (!imageRef || !imageLoaded) return
return imageRef.getBoundingClientRect()
}, [imageRef, imageLoaded])
useEffect(() => {
if (!imageRef || !canvasRef || !imageLoaded || !imageBoundingRect) return
const canvas = canvasRef
const ctx = canvas.getContext("2d");
if (!ctx) return
ctx.filter = `blur(${blurAmount}px)`;
ctx.drawImage(imageRef, 0, 0, imageBoundingRect.width, imageBoundingRect.height);
// const dataurl = canvas.toDataURL()
canvas.toBlob(blob => {
if (!blob) return
const imageUrl = URL.createObjectURL(blob);
setBlurryUrl(imageUrl)
})
}, [imageLoaded, imageRef, canvasRef, imageBoundingRect, blurAmount])
/**
* Calculate the background position of each pane
* based on the pane size and the number of panes as well as the justify prop
*/
const calculatePaneBackgroundPos = useCallback((i: number) => {
if (paneJustify === 'start') {
return `${(i * singlePaneWidth) / (100 / paneSize)}%`
}
return `${(100 - paneSize) + (i * singlePaneWidth) / (100 / paneSize)}%`
}, [paneJustify, singlePaneWidth, paneSize])
/**
* The width of the background image is the number of panes * 100%
*/
const backgroundSizeWidth = useMemo(() => {
return numOfPanes * (100 - stretchPercentage)
}, [numOfPanes, stretchPercentage])
return (
<Wrapper $active={active} $hover={activeOnHover} $side={paneJustify} {...props}>
{children}
{image &&
<StyledImage onLoad={() => {setImageLoaded(true)}} ref={useImageRef} src={image}/>
}
{imageLoaded && (
<>
<StyledCanvas ref={useCanvasRef} width={imageBoundingRect?.width} height={imageBoundingRect?.height}/>
<PanesWrapper $width={paneSize}>
{blurryUrl && Array(numOfPanes).fill(0).map((_, i) => i).map((i) => (
<Pane
style={{
backgroundImage: `url(${blurryUrl})`,
backgroundSize: `${backgroundSizeWidth}% 84%`,
backgroundPositionX: calculatePaneBackgroundPos(i)
}}
$animationMs={animationMs}
key={`pane-${i}`}
/>
))}
</PanesWrapper>
</>
)}
</Wrapper>
)
}
const StyledImage = styled.img`
display: block !important;
`
const StyledCanvas = styled.canvas`
display: none;
`
const PanesWrapper = styled.div<{$width: number}>`
width: ${({$width}) => $width}%;
height: 100%;
top: 0;
position: absolute;
overflow: hidden;
display: flex;
`;
const Pane = styled.div<{
$animationMs: number,
}>`
background-repeat: no-repeat;
position: relative;
height: 120%;
top: -10%;
background-position-y: center;
flex: 1 0 auto;
transform: perspective(100px) rotateY(90deg);
transition-timing-function: ease-in-out, ease-out;
${({$animationMs}) => $animationMs && css`
transition: transform ${$animationMs}ms ease-in-out;
`}
`;
const Wrapper = styled.div<{
$side: Props['paneJustify'],
$hover: Props['activeOnHover'],
$active: Props['active']
}>`
display: flex;
position: relative;
overflow: hidden;
justify-content: flex-${({$side}) => $side};
widht: 100%;
${({$hover}) => $hover && css`
&:hover ${Pane} {
transform: perspective(200px) rotateY(12deg);
}
`}
${({$active}) => $active && css`
${Pane} {
transform: perspective(200px) rotateY(12deg);
}
`}
`;Usage example

import { FlutedGlass } from "@fullstack-rocks/ui"
import { useState } from "react"
import styled from "styled-components"
export const ComponentDemo = () => {
const [isActive, setIsActive] = useState(true)
return (
<ExampleWrapper>
<FlutedGlass
active={isActive}
image={'/images/articles/fluted_glass/neon_streetwear.png'}
numOfPanes={60}
paneSize={100}
blurAmount={2}
stretchPercentage={75}
animationMs={190}
activeOnHover={false}
paneJustify="start"
>
<ExampleContent>
<Block>
<BlockButton onMouseEnter={() => setIsActive(false)} onMouseLeave={() => setIsActive(true)}>
EXPLORE OUR NEWEST COLLECTION
</BlockButton>
</Block>
</ExampleContent>
</FlutedGlass>
</ExampleWrapper>
)
}
const ExampleWrapper = styled.div`
position: relative;
color: initial;
`
const ExampleContent = styled.div`
display: flex;
position: absolute;
flex-direction: row;
align-items: flex-end;
width: 100%;
height: 100%;
z-index: 1;
padding-right: 5%;
`
const Block = styled.div`
/* background: white; */
display: flex;
padding: 20px;
font-family: "Times New Roman", serif;
flex-direction: column;
gap: 20px;
`
const BlockButton = styled.button`
border-radius: initial;
border: 1px solid black;
font-family: inherit;
font-weight: 200;
font-size: 1.1rem;
padding: 10px 20px;
background: white;
cursor: pointer;
&:hover {
background: black;
color: white;
}
`;Final thoughts
This component was an engaging challenge that went through several iterations and refinements, and I’m proud of the final result. While it may not be perfect or the most performant, projects like this are a great way to push creative boundaries and experiment with visually appealing effects.
More than just a visual exercise, this component highlights the importance of understanding web data types on a deeper level. For instance, leveraging tools like Blob URLs, often overlooked in day-to-day CSS or JavaScript work, proved instrumental in achieving the desired effect here. It’s a reminder that technical depth can often unlock creative possibilities.
I hope this article has provided you with insights and inspiration to experiment with your own projects.
Thank you for reading.

Today I'm writing and architecting software using many different technologies, and I'm always looking for the next thing to learn.