Skip to content
All posts

10 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' },
});
This will not run in Expo Go. React Native Skia is not bundled into Expo Go, so you need a development build (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