ShineBorder
Animated border with sweeping shine.
Live demo
01Featured content
The default ShineBorder draws attention with a slow, even sweep. Hover to inspect the animation; it never pauses, so layout doesn't reflow.
#146ef5#10b981#8b5cf6#f59e0bduration=1.5duration=3duration=6Implementation
02<script>
import ShineBorder from '$lib/components/ShineBorder.svelte';
</script>
β
<ShineBorder color="#146ef5" duration={3} borderWidth={2} borderRadius={8}>
<div class="card">
Featured content
</div>
</ShineBorder>ShineBorder paints a conic-gradient sweep onto a pseudo-element that masks the wrapped content. The animation runs purely in CSS (no rAF, no JS state), so it stays cheap regardless of how many borders are on the page. Colour, duration, width, and radius are all standalone props β change one without touching the others.
Logic explainer
03What Does It Do? (Plain English)
ShineBorder wraps any block of content in a border that has a bright stripe of colour sliding across it forever β the chrome-trim glint you see on premium product cards. There is no real CSS border involved: the wrapper paints a wide horizontal gradient, the inner content sits on a solid background that hides the centre of that gradient, and only the padding around the edge is left exposed. Animate the gradient's horizontal position and the exposed edge appears to shimmer.
Think of it like a theatre stage where the spotlight is twice as wide as the stage, sweeping in from the wings on the left and exiting through the wings on the right. The audience only sees the slice that crosses the stage β and because the spotlight enters and exits offstage, there is never a visible jump back to the start.
How It Works (Pseudo-Code)
on render:
read props: color, duration, borderWidth, borderRadius
emit outer wrapper:
background = linear-gradient(90deg, transparent, color, transparent)
background-size = 200% 100% // gradient is twice as wide as visible area
padding = borderWidth // padding becomes the visible "border" strip
border-radius = borderRadius
animation = shine-border-animation duration linear infinite
emit inner content:
background = solid (white)
border-radius = borderRadius - borderWidth
render { children }
@keyframes shine-border-animation:
0% β background-position = -200% 0 // bright band offstage left
100% β background-position = 200% 0 // bright band offstage rightThe component never touches JavaScript at runtime. Every shimmer you see is the browser's compositor smoothly interpolating one declarative property (background-position) on one element.
The Core Concept: The "Padding-As-Border" Trick
Most CSS borders use the actual border property and so cannot show a gradient (CSS border-image works but is a different beast). ShineBorder gets around that with a layered sandwich:
ββββββββββββββββββββββββββββββββββββββ
β wrapper β gradient background β β only this layer is animated
β ββββββββββββββββββββββββββββββββ β
β β β β
β β inner β solid background β β covers the gradient's centre
β β { children render } β
β β β β
β ββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββ
β
padding = borderWidth: this strip is NOT covered by the inner div,
so it shows the wrapper's animated gradient β i.e. the "border".The visible band of colour appears because the wrapper's gradient is transparent β colour β transparent. At any instant only one slice of that band is over the visible padding strip; sliding the gradient horizontally is what makes the slice travel around the frame.
Why a 200%-wide gradient? If the gradient were only as wide as the wrapper, animating from 0% to 100% would require a snap back to 0% to repeat, which the eye reads as a stutter. Going from -200% to +200% lets the bright band enter from offstage left, cross the visible area, and exit offstage right before the next loop begins entirely offstage β the loop is invisible.
CSS Animation Strategy
Everything is GPU-friendly. The only animated property is background-position, which the compositor handles without triggering layout or paint on the inner content. The wrapper carries will-change: background-position so the browser hoists it to its own layer up front rather than promoting mid-animation.
.shine-border-wrapper {
background: linear-gradient(90deg, transparent, var(--shine-color), transparent);
background-size: 200% 100%;
animation: shine-border-animation var(--shine-duration) linear infinite;
will-change: background-position;
}
@keyframes shine-border-animation {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}Reduced motion is documented in the component as a known TODO. The recommended override is:
@media (prefers-reduced-motion: reduce) {
.shine-border-wrapper { animation-duration: 0.01s; }
}This freezes the gradient at its starting offset rather than disabling it entirely β the static band still reads as a styled border for users who would otherwise lose the visual cue.
State Flow Diagram
ββββββββββββββββββββββββ
β initial render β
β CSS vars from props β
ββββββββββββ¬ββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββ
β CSS animation begins (browser-driven) β
β background-position: -200% β +200% β
β loops linearly forever β
ββββββββββββββββββββββββββββββββββββββββββ
β
prop changes (color / duration / width / radius)
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββ
β CSS custom properties re-emit β
β animation continues from current pos β
ββββββββββββββββββββββββββββββββββββββββββ
prefers-reduced-motion: reduce (recommended override)
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββ
β duration β 0s β effectively static β
ββββββββββββββββββββββββββββββββββββββββββThere is no runtime state to track β the component is a thin bag of CSS variables wrapping a {children} render slot.
Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
color |
string |
'#146ef5' |
Any CSS colour. Drives the gradient mid-stop. |
duration |
number |
3 |
Seconds for one full sweep across the visible area. |
borderWidth |
number |
2 |
Padding (px) around the inner content β this strip is the visible "border". |
borderRadius |
number |
8 |
Corner radius (px) for the wrapper. The inner div uses radius β width. |
children |
Snippet |
β | Wrapped content. Sits over the solid inner background. |
Edge Cases
| Situation | Behaviour |
|---|---|
children omitted |
Empty bordered box renders. The shimmer still runs but there is no content slot. |
borderWidth = 0 |
The inner div fills the wrapper exactly. The gradient is fully covered and you see no shine. |
borderRadius < borderWidth |
The inner radius is computed as radius β width and clamps to 0 in CSS β the inner corners become sharp while the outer keeps the requested radius. Content inside the inner div may sit awkwardly close to the edge; pad your child element. |
duration = 0 |
Browsers treat it as no animation; the gradient renders frozen at its starting offset (-200% β band offstage left). Visually, the border appears solid-transparent. Avoid; use the reduced-motion override instead. |
| Very small wrapper (e.g. 24Γ24 px) | The 200% gradient is still wider than the wrapper β no visual problem β but the borderRadius β borderWidth calc may produce a tiny inner radius that looks inconsistent with the outer. |
prefers-reduced-motion: reduce |
Component does not yet honour this automatically. Consumers should either layer the recommended @media override, or omit ShineBorder for users with the preference set. |
| Multiple ShineBorders on one page | Each runs independently; there is no shared timer. They will drift out of phase, which usually looks better than synchronised shimmer. |
Dependencies
- Svelte 5 β
$props()rune to read configuration; snippet slot for children. - Zero external libraries β no animation library, no icon library, no framework. Pure CSS keyframes.
$lib/typesβShineBorderPropsinterface for type safety across consumers.
File Structure
src/lib/components/ShineBorder.svelte # implementation
src/lib/components/ShineBorder.md # this explainer (rendered in ComponentPageShell)
src/lib/components/ShineBorder.test.ts # unit tests
src/routes/shineborder/+page.svelte # demo page
src/lib/types.ts # ShineBorderProps interfaceAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
color | string | '#146ef5' | Any CSS colour value. Drives the animated shine. |
duration | number | 3 | Sweep duration in seconds. Lower is faster. |
borderWidth | number | 2 | Border thickness in pixels. |
borderRadius | number | 8 | Corner radius in pixels. |