Mehdi Davoodi10 min read
React Native electric border: the React Bits effect on Skia
React Native has no SVG displacement filter, so the web electric border does not port. The Skia technique that replaces it, and why the arc wobbles.
To draw an electric border in React Native, walk the outline of a rounded
rectangle, push every point off that outline with fractal noise, and stroke
the resulting polyline a few times in React Native Skia. You cannot port the
web version directly, because the web version is an SVG
feTurbulence and feDisplacementMap filter and React
Native has no equivalent primitive. You have to rebuild the displacement
yourself.
The effect people mean by "electric border" is the Electric Border component from React Bits: a card with a live arc of electricity crawling around its edge. It is a lovely piece of work, and every few weeks somebody asks for it in React Native. I packaged my answer as expo-electric-border, which is free and MIT. This post is the part that is worth more than the package: what the technique is, and the three or four decisions that separate something that looks electric from something that looks like a wobbly rectangle.
Why can't you port the web electric border directly?
Because the web version's entire effect lives in one SVG filter.
feTurbulence generates a Perlin noise texture, and
feDisplacementMap uses that texture to shove the pixels of the
source graphic around. The browser runs both in the compositor. You write a
static border, the filter makes it crawl.
React Native has no filter pipeline like that. react-native-svg
does not implement feDisplacementMap, and there is no view style
that displaces pixels. So the port is not a port. You have to move the
displacement from the pixel stage to the geometry stage: instead of drawing a
clean border and distorting the pixels, generate distorted geometry and draw
that.
| React Bits (web) | React Native | |
|---|---|---|
| Noise source | feTurbulence |
Value noise you write yourself |
| Displacement | feDisplacementMap on pixels |
Offset applied to path points |
| What gets drawn | A clean border, then distorted | An already-distorted polyline |
| Runs on | Browser compositor | UI thread, in a Reanimated worklet |
| Glow | CSS filter: blur() layers |
Stacked Skia strokes with BlurMask |
That difference has one nice consequence. Because the geometry is rebuilt in a worklet rather than driven by CSS, the effect's parameters can be Reanimated shared values, and a gesture can drive them with no React render in the loop. The web version cannot do that without a state update per frame.
How do I add an electric border to a React Native app?
If you just want the effect, install the package. It is JavaScript only, so there is no native module to link, but it leans on three peers that do the real work.
npx expo install expo-electric-border @shopify/react-native-skia react-native-reanimated react-native-worklets
import { ElectricBorder } from 'expo-electric-border';
import { StyleSheet, Text } from 'react-native';
export function ElectricCard() {
return (
<ElectricBorder style={styles.card} color="#5CC8F2" borderRadius={28}>
<Text style={styles.title}>Electric Card</Text>
</ElectricBorder>
);
}
const styles = StyleSheet.create({
card: { width: 320, height: 430, borderRadius: 28, padding: 24 },
title: { fontSize: 24, fontWeight: '600' },
});
npx expo run:ios, npx expo run:android, or an EAS
dev build). Reanimated 4 also requires the New Architecture, which has been
the default since React Native 0.76 and Expo SDK 52. Neither requirement comes
from the border itself.
The component measures itself, so it needs a size: put a width and height on
style, or let the children supply one. And keep
overflow visible on it and on whatever wraps it, because the glow
is drawn outside the box bounds and a hidden overflow will slice it off. That
last one accounts for most of the "the glow is cut off" reports.
How do you displace a rounded rectangle with noise?
Walk the outline by arc length. Compute the perimeter of the rounded rectangle, pick a sample every few pixels, and for each sample ask two questions: where is this point on the clean outline, and how far off it should it sit this frame.
Sampling by arc length rather than by angle or by side matters. It keeps the samples evenly spaced around the corners, so the arc does not bunch up where the radius curves and thin out along the straights.
const SAMPLE_SPACING = 3; // px between samples
const WANDER_SCALE = 30; // px of travel at full swing
const perimeter = outlineLength(width, height, radius);
const samples = Math.max(64, Math.round(perimeter / SAMPLE_SPACING));
builder.reset();
for (let i = 0; i < samples; i++) {
const progress = i / samples;
// Two independent slices of the same noise field, so x and y do not
// correlate into a diagonal drift.
const offsetX = fractalNoise(progress * NOISE_PERIODS, 0, time, amplitude);
const offsetY = fractalNoise(progress * NOISE_PERIODS, 1, time, amplitude);
outlinePointAt(cursor, progress * perimeter, width, height, radius);
const x = cursor.x + offsetX * WANDER_SCALE;
const y = cursor.y + offsetY * WANDER_SCALE;
if (i === 0) builder.moveTo(x, y);
else builder.lineTo(x, y);
}
builder.close();
Then stroke that one polyline more than once. A single bright line reads as a
scribble, not as light. What sells it is the falloff: a wide, dim, box-shaped
haze underneath, two glow passes at decreasing width and increasing opacity,
and a thin near-white filament on top. The two glow passes use
blendMode="plus", so wherever the arc doubles back on itself the
overlap burns out toward white. That additive blowout is most of what your eye
reads as electricity.
Why does the arc wobble instead of crackle?
This is the one that took longest to find, and it is a single line. Standard fractal noise sums octaves starting from the lowest frequency. That lowest octave is slow and wide, and if you leave it in it drags the whole outline off the box in long lazy arcs. The border stops looking electric and starts looking like a flag in wind.
Start the loop one octave in. The low-frequency component disappears, the outline stays welded to the border, and only the fast detail moves. That is the difference between a wobble and a crackle.
export function fractalNoise(x, seed, time, amplitude) {
'worklet';
let total = 0;
let amp = amplitude * GAIN;
let frequency = BASE_FREQUENCY * LACUNARITY;
// octave starts at 1, not 0. Skipping the lowest octave is what keeps
// the arc attached to the border instead of sailing away from it.
for (let octave = 1; octave < OCTAVES; octave++) {
total += amp * valueNoise(frequency * x + seed * SEED_STRIDE, time * frequency * TIME_SCALE);
frequency *= LACUNARITY;
amp *= GAIN;
}
return total;
}
There is a second, subtler version of the same class of bug. If you port a
GLSL hash function to JavaScript, you will probably see
fract(sin(n) * 43758.5453) and reach for a fract equivalent.
fract folds the result into [0, 1), which makes every
sample positive, which means every sample pushes the outline the same
direction. The border bows outward and breathes instead of weaving. Keep the
hash signed, in (-1, 1), so displacement can go both ways across
the border.
// JavaScript's % keeps the sign of the dividend, which is exactly what we want
// here. A fract() port would fold this into [0, 1) and bow the outline outward.
function hash(n: number) {
'worklet';
return (Math.sin(n * 12.9898) * 43758.5453) % 1;
}
Why does the border snap at one corner?
Because the loop is closed but the noise is not. You sample the noise field over an open interval from 0 to 1, so the value at the very end of the lap has no relationship to the value at the start. Where the path closes, the two ends sit at different displacements and you get a visible snap, on the order of fifteen pixels at default settings. It reads as a glitch on one corner, and once you have seen it you cannot unsee it.
Fix it by crossfading the tail of the lap into the displacement the start is using. Sample the field one full lap early and smoothstep between the two over the last few percent.
const SEAM_BLEND = 0.06; // last 6% of the lap
if (progress > 1 - SEAM_BLEND) {
const t = (progress - (1 - SEAM_BLEND)) / SEAM_BLEND;
const blend = t * t * (3 - 2 * t); // smoothstep
const wrapped = (progress - 1) * NOISE_PERIODS; // one lap early
offsetX += (fractalNoise(wrapped, 0, time, amplitude) - offsetX) * blend;
offsetY += (fractalNoise(wrapped, 1, time, amplitude) - offsetY) * blend;
}
How do you keep the whole thing off the JS thread?
Rebuilding a few hundred path points per frame is fine on the UI thread and ruinous across the bridge. So the path has to be built inside a worklet. Two constraints shape how.
First, you cannot construct a Skia object inside a worklet. The Skia global is
not installed on the worklet runtime and it will crash. Create one
Skia.PathBuilder on the JS thread, park it in a shared value, and
have the worklet only reset and re-fill it. That is also the faster shape,
since you are mutating one long-lived builder rather than allocating per
frame. Skia.PathBuilder is why the package floors at React Native
Skia 2.6.
Second, and this is the trap: do not use Skia's usePathValue,
even though it is the hook that appears to be built for exactly this.
usePathValue builds on useDerivedValue, whose mapper
takes its dependencies from the entire worklet closure, and that closure
includes the path it writes to. Writing the result re-triggers the mapper. The
outline then rebuilds every frame forever, whether or not anything is
animating, and a border you thought you had frozen quietly keeps burning CPU.
useAnimatedReaction does not have that problem, because its
dependencies come from the prepare worklet alone. Read only the
clock and the animated inputs there, write the path in react, and
the feedback loop never forms.
const builder = useSharedValue(useMemo(() => Skia.PathBuilder.Make(), []));
const path = useSharedValue(useMemo(() => Skia.Path.Make(), []));
useAnimatedReaction(
() => {
// Whatever this worklet reads is what re-triggers a rebuild. Shared values
// have to be read right here: a read one call deeper never registers.
return {
time: seconds.value,
chaos: typeof chaos === 'number' ? chaos : chaos.value,
width,
height,
radius,
};
},
(frame) => {
'worklet';
buildOutline(builder.value, frame);
path.value = builder.value.build();
}
);
What does an electric border cost?
While it is moving: a few hundred samples per frame, each summing seven octaves of noise, plus four strokes with blur masks over a canvas larger than the card. That is comfortably real time for a handful of borders on a modern phone, and it is not something to put in a list cell that renders forty times.
The number that matters is what it costs when it is not moving,
because that is the common case. A card scrolled off screen, or sitting behind
a modal, should cost nothing. With the useAnimatedReaction shape
above, and a frame callback that unregisters itself at zero speed, it does:
freezing a border in the example app takes it from roughly 45% CPU to 1% on an
iOS simulator.
// Unregisters the frame loop entirely, rather than animating at rate zero.
<ElectricBorder speed={isVisible ? 1 : 0}>{children}</ElectricBorder>
Wire that to a FlatList viewability callback or a navigation
focus hook and the effect stops being something you have to budget for. The
other three levers all widen the canvas rather than the maths:
chaos, thickness and glow each cost
fill rate, and an animated chaos sizes the canvas for the whole
range up front because a shared value cannot be read during layout.
What about reduced motion?
An animation that never stops, on the edge of a card, is exactly the sort of
thing the reduced motion setting exists for. Read
useReducedMotion() from Reanimated and hold the arc still when it
is on. The important part is that the border still draws: the design
survives, only the motion goes. Suppressing the whole component would leave a
hole in the layout, which is worse than the animation was.
expo-electric-border does this without being asked, so there is
nothing to wire up. It is worth knowing anyway, because the first time you
test with the setting on you will wonder why your border stopped moving.
How do you make an electric border in React Native?
Walk the outline of a rounded rectangle by arc length, displace every sample
with fractal noise, and stroke the resulting polyline several times in React
Native Skia: a wide dim haze, two additive glow passes, and a thin bright
filament. The web approach of an SVG feTurbulence plus
feDisplacementMap filter has no React Native equivalent, so the
displacement has to move from the pixel stage to the geometry stage. Build the
path inside a Reanimated worklet so the per-frame work never crosses the
bridge, or install
expo-electric-border,
which packages exactly this.
Does the React Bits Electric Border work in React Native?
No. The React Bits component is React DOM and its effect is an SVG filter
chain, specifically feTurbulence feeding
feDisplacementMap. React Native has no displacement filter, and
react-native-svg does not implement those filter primitives, so
the component cannot be copied across. The look can be reproduced, but it has
to be rebuilt from noise and Skia strokes rather than ported.
Does an electric border work in Expo Go?
No, because React Native Skia is not bundled into Expo Go. You need a
development build, made with npx expo run:ios,
npx expo run:android, or EAS. Reanimated 4 additionally requires
the New Architecture, which has been the default since React Native 0.76 and
Expo SDK 52. Neither restriction comes from the border component itself, and
no custom native module is involved.
Why does my animated border look like a wobble instead of lightning?
Almost always because the lowest octave of the fractal noise is still in the
sum. That octave is slow and wide, and it drags the entire outline off the box
in long arcs, which reads as a flag in wind rather than as electricity. Start
the octave loop at 1 instead of 0 so only the fast detail contributes. A
related bug bows the outline outward instead of weaving it: that one comes
from porting a GLSL hash with fract, which folds the value into
[0, 1) and makes every displacement push the same direction. Keep
the hash signed.
Why does the animated border glitch at one corner?
Because the path is a closed loop but the noise field is sampled over an open interval, so the start and end of the lap sit at unrelated displacements and visibly snap where they meet. Crossfade the last few percent of the lap into the displacement the start is using, sampling the noise field one full lap early and smoothstepping between the two values. Six percent of the lap is enough to hide the seam completely.
Is Skia's usePathValue the right hook for a per-frame path?
Not when the animation can stop. usePathValue is built on
useDerivedValue, whose mapper draws its dependencies from the
whole worklet closure, including the path it writes to, so writing the result
re-triggers the mapper and the path rebuilds every frame forever regardless of
whether anything changed. Use useAnimatedReaction instead, which
takes its dependencies only from its prepare worklet, and read
just the clock and your animated inputs there. A frozen border then schedules
no work at all.
The short version
An electric border in React Native is geometry, not a filter. Walk the outline
by arc length, displace it with fractal noise that starts an octave in, keep
your hash signed, stitch the seam shut, and stroke the result four times with
additive blending. Build it in a worklet with a long-lived
Skia.PathBuilder and drive it with
useAnimatedReaction, not usePathValue, so a still
border is genuinely still.
All of that is packaged in expo-electric-border, free and MIT, with credit to React Bits for the original. If you want more React Native components built with this much attention to what happens on the UI thread, the component reference is free, and the catalog is where the rest of it lives.
React NativeExpoSkiaReanimatedAnimationOpen source