Breaking Down Jhey's Shooting Star Border
This blogpost was created to try and break down Jhey's shooting star border because I was curious how it worked.
I've tried reading other resources like this but I wasn't very satisfied with the details, so I decided to build up on top of it and break down all the CSS and understand how they were used.
Demo
The Basic HTML Needed
This is using React with CSS module styles applied on it
<button style={buttonStyles} className={styles.button}> <span className={styles.spark} /> <span className={styles.backdrop} /> <span className={styles.text}>Shooting star border</span></button>
Setup basic styles
Position relative
Used to set up the button as the parent container for the spark and backdrop to be placed absolutely inside of it
Box shadow
Used to fake the border effect
.button { padding: 6px 18px; display: grid; border-radius: var(--border-radius); overflow: hidden; position: relative; box-shadow: 0 1000px 0 0 var(--box-shadow-color) inset;}
Short explanation of some uncommon CSS properties
Relative
By using display: relative;
, the elements with display: absolute;
within it are placed with respect to it.
So, if there is an element that has position: absolute; top: 0; right: 0;
It would be placed at the top right corner of the parent container.
Inset
Inset is a shorthand for top
, right
, bottom
, left
properties.
Eg: inset: 1px 2px 3px 4px;
is the same as top: 1px; right: 2px; bottom: 3px; left: 4px;
And when used with position: absolute;
, you can think of it as having the same width and height as the parent container,
but it has a 1px gap on the top, 2px gap on the right, 3px gap on the bottom and 4px gap on the left.
When used in box-shadow
, the box-shadow is displayed inside the element, and this is how we fake the border effect.
Box-shadow
We set the offset-x to 0 (1st value), offset-y to 1000px (2nd value), blur-radius to 0 (3rd value) and spread-radius to 0 (4th value).
The fake border effect can only be achieved when the offset-y is >= the height of the button. Thus, we set a high value for it.
It can also be achieved with offset-x >= width of the button.
Masking the Spark
Inset 0
Equivalent to top: 0; right: 0; bottom: 0; left: 0;
, this means it has the same width and height as the parent container.
Mask
We use a linear gradient to create a mask for the spark.
You can see how the mask looks like below.
Animation
We animate with the flip keyframes, which rotates the mask 360deg at 2x the spark duration.
We animate it forever with infinite
and steps(2, end)
makes it so that the animation only has 2 frames, the start and end.
.spark { position: absolute; inset: 0; border-radius: var(--border-radius); rotate: 0deg; mask-image: linear-gradient(white, transparent 50%); animation: flip calc(var(--spark) * 2) infinite steps(2, end);}@keyframes flip { to { rotate: 360deg; }}
Mask
A mask is used as a "cover" to hide parts of an inner element.
We will be using the mask to cover parts of the actual spark (.spark:before) to create the shooting star effect.
Since gradients (linear and conic gradients, etc.) can only be used as background-image, we use mask-image
to apply the mask.
::before
::before
and ::after
are pseudo-elements and they can be thought as spans that are placed before and after the text content.
They must have the content
property set in order to work.
The Actual Spark
Size
The width is twice of the width of the .spark element, and the height is the same as the width with aspect-ratio 1.
Inset
The inset is equivalent to top:0; left: 50%;
, using auto means the properties(right, bottom) are not set.
Rotate
We rotate from left to top to right, -90deg to 90deg.
Conic-gradient
This acts as the shooting star's trail.
.spark::before { content: ""; position: absolute; width: 200%; aspect-ratio: 1; inset: 0 auto auto 50%; z-index: -1; translate: -50% -15%; rotate: -90deg; opacity: 1; background-image: conic-gradient( from 0deg, transparent 0 340deg, var(--spark-color) 360deg ); animation: rotate var(--spark) linear infinite both;}@keyframes rotate { to { transform: rotate(180deg); }}
Demo
This is the conic gradient that acts as the shooting star's trail.
And below shows it in action, where the green border is the ::before element, the blue border is the .spark element.
We can see that the mask covers half of the button, and the conic gradient is only shown on the other half.
Note that the flip animation on the .spark element also applies to the ::before element. That is why we are seeing the ::before element rotating and flipping.
Last but not least, we have the backdrop with inset 1px.
This allow for a gap of 1px to show the spark at the button's "border".
.backdrop { position: absolute; inset: 1px; background-color: var(--backdrop-color); border-radius: var(--border-radius);}.button:hover .backdrop { background-color: var(--backdrop-hover-color);}
Here, the full code is shown below.
.button { padding: 6px 18px; display: grid; border-radius: var(--border-radius); overflow: hidden; position: relative; box-shadow: 0 1000px 0 0 var(--box-shadow-color) inset;}.spark { position: absolute; inset: 0; border-radius: var(--border-radius); rotate: 0deg; mask-image: linear-gradient(white, transparent 50%); animation: flip calc(var(--spark) * 2) infinite steps(2, end);}@keyframes flip { to { rotate: 360deg; }}.spark::before { content: ""; position: absolute; width: 200%; aspect-ratio: 1; inset: 0 auto auto 50%; z-index: -1; translate: -50% -15%; rotate: -90deg; opacity: 1; background-image: conic-gradient( from 0deg, transparent 0 340deg, var(--spark-color) 360deg ); animation: rotate var(--spark) linear infinite both;}@keyframes rotate { to { transform: rotate(180deg); }}.backdrop { position: absolute; inset: 1px; background-color: var(--backdrop-color); border-radius: var(--border-radius);}.button:hover .backdrop { background-color: var(--backdrop-hover-color);}.text { z-index: 1; color: rgb(203 213 225);}
Hope you have learnt something from the blog post above!
Feel free to reach out to me if you have any questions or feedbacks!