Component Breakdown: AI Search Bar

Yujin YiYujin Yi
October 13, 2026Engineering

Welcome to our Component Breakdown series! In this series, I'll be presenting a thorough walkthrough of some of the code that runs the visuals in Butter's videos. We recently released this one:

Butter 2.0 Sizzle Reel

Everything in here is made with code, so let's look at what's involved. We're looking at this piece specifically, the AI search bar:

AI Search Bar Component

I've broken down the components into 4 parts:

  1. Border Glow
  2. Typing Animation
  3. Gradient Sweep Blur
  4. Icon Button

Let's go through each of these parts to break down how they work.

Overview of Steps

Part 1. Border Glow

To achieve the rotating border glows, I'm using a native 2D canvas conic gradient. These are fills that change color based on the angle around a center point. You can use the gradient stops to control the glow width and animate it by simply putting frameCount as the angle.

There is always more than one method to achieving a visual effect and they come with ups and downs. While a conic gradient is a cheap way to simulate the effect, it has a limit: being based on the angle, the gradient always has the symmetry of a circle, even if we're using it to fill in a rectangle. Although the angular width of the highlight stays the same, it will appear a different length as it goes around the rectangle.

let ctx;
let w, h, r, x, y;

function setup() {
  createCanvas(400, 400);
  angleMode(DEGREES);
  
  ctx = drawingContext;

  w = 300;
  h = 150;
  r = 20;
  x = width / 2 - w / 2;
  y = height / 2 - h / 2;

  createSpan("Glow Width");
  glowWidth = createSlider(0,1,0.5,0.01);
}

function draw() {
  background(255);
  
  const cx = x + w / 2;
  const cy = y + h / 2;

  const gradient = ctx.createConicGradient(radians(frameCount), cx, cy);
  gradient.addColorStop(0, "white");
  gradient.addColorStop(0.5 - glowWidth.value() / 2, "white");
  gradient.addColorStop(0.5, "blue");
  gradient.addColorStop(0.5 + glowWidth.value() / 2, "white");
  gradient.addColorStop(1, "white");

  strokeWeight(10);
  ctx.strokeStyle = gradient;
  rect(x, y, w, h, r);
}

Now let's add styles to the border glow: softness, opacity, and blur.

Softness is how quickly the ends blend out. In the earlier version, the color had two segments: jumped straight from white to blue, and back again. Below, we've changed the band to have three zones: a solid plateau in the middle, and a fade on each side.

Opacity is how transparent the glow is. We'll keep a separate value alpha for that.

Blur is how far the light spreads. The glow effect comes from layering two strokes of the same path.

let ctx;
let w, h, r, x, y;
let glowWidth, softness, opacity, blur, thickness;

function setup() {
  createCanvas(400, 400);
  ctx = drawingContext;

  w = 300;
  h = 150;
  r = 20;
  x = width / 2 - w / 2;
  y = height / 2 - h / 2;

  createSpan("Glow Width");
  glowWidth = createSlider(0, 0.5, 0.15, 0.01);
  
  createElement("br");
  createSpan("Thickness");
  thickness = createSlider(1, 10, 2, 1);
  
  createElement("br");
  createSpan("Softness");
  softness = createSlider(0, 1, 0.5, 0.01);
  
  createElement("br");
  createSpan("Blur");
  blur = createSlider(0, 30, 12, 1);

  createElement("br");
  createSpan("Opacity");
  opacity = createSlider(0, 1, 0.2, 0.1);
}

function makeGradient(cx, cy, alpha) {
  const gw = glowWidth.value();
  const plateau = gw * (1 - softness.value());

  const core = `rgba(0, 0, 255, ${alpha})`;
  const clearColor = "rgba(0, 0, 255, 0)";

  const g = ctx.createConicGradient(radians(frameCount), cx, cy);
  g.addColorStop(0, clearColor);
  if (gw < 0.5) g.addColorStop(0.5 - gw, clearColor);
  g.addColorStop(0.5 - plateau, core);
  g.addColorStop(0.5, core);
  g.addColorStop(0.5 + plateau, core);
  if (gw < 0.5) g.addColorStop(0.5 + gw, clearColor);
  g.addColorStop(1, clearColor);

  return g;
}

function draw() {
  background(255);

  const cx = x + w / 2;
  const cy = y + h / 2;
  const a = opacity.value();
  const lw = thickness.value();

  ctx.beginPath();
  ctx.roundRect(x, y, w, h, r);

  ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
  ctx.lineWidth = lw;
  ctx.stroke();

  ctx.save();
  ctx.filter = `blur(${blur.value()}px)`;
  ctx.strokeStyle = makeGradient(cx, cy, a);
  ctx.lineWidth = lw * 3.5;
  ctx.stroke();
  ctx.restore();

  ctx.strokeStyle = makeGradient(cx, cy, 1);
  ctx.lineWidth = lw;
  ctx.stroke();
}

Pixel Density

When using filters through the drawing context in p5.js, there's one thing to watch out for: multiply pixel-based values by pixelDensity(). Otherwise, the sketch will look different on high-DPI screens and in higher-resolution exports.

Here's what that looks like:

ctx.filter = `blur(${blur.value() * pixelDensity()}px)`;

Why do we need this? A p5 canvas often contains more real pixels than its createCanvas() size, and pixelDensity() is that ratio. It defaults to your screen's density, which is 2 on most modern displays. So pixel density 2 means 400×400 canvas = 800×800 pixels.

The underlying 2D canvas doesn't know about p5's scaling. Behind the scenes, p5 scales the drawing context by the pixel density, so that geometry (positions, sizes, line widths, etc.) looks the same at any density.

So anything that goes through drawingContext, skipping p5's own methods, won't have this done automatically. For example, blur(10px) means exactly 10 real/absolute pixels, regardless of the density. So at density 2 the blur will look only half as strong as intended.

Making Multiple Glows

Let's now develop it into a class so we can have multiple glows on one rectangle. To do that, draw the instances and spread them evenly by varying the phase.

let ctx;
let track;
let glows = [];
let glowWidth, softness, opacity, blur, thickness, numberOfGlows;

class Glow {
  constructor(phase) {
    this.phase = phase;
  }

  gradient(angle, gw, soft, alpha) {
    const plateau = gw * (1 - soft);
    const core = `rgba(0, 0, 255, ${alpha})`;
    const clear = "rgba(0, 0, 255, 0)";

    const start = angle + this.phase * TWO_PI;
    const g = ctx.createConicGradient(start, track.cx, track.cy);

    g.addColorStop(0, clear);
    if (gw < 0.5) g.addColorStop(0.5 - gw, clear);
    g.addColorStop(0.5 - plateau, core);
    g.addColorStop(0.5, core);
    g.addColorStop(0.5 + plateau, core);
    if (gw < 0.5) g.addColorStop(0.5 + gw, clear);
    g.addColorStop(1, clear);

    return g;
  }

  draw(angle, gw, soft, a, lw, blurPx) {
    ctx.save();
    ctx.filter = `blur(${blurPx}px)`;
    track.path();
    ctx.lineWidth = lw * 3.5;
    ctx.strokeStyle = this.gradient(angle, gw, soft, a);
    ctx.stroke();
    ctx.restore();

    track.path();
    ctx.lineWidth = lw;
    ctx.strokeStyle = this.gradient(angle, gw, soft, 1);
    ctx.stroke();
  }
}

function buildGlows() {
  const n = int(numberOfGlows.value());
  glows = [];
  for (let i = 0; i < n; i++) glows.push(new Glow(i / n));
}

function setup() {
  createCanvas(400, 400);
  ctx = drawingContext;

  const w = 300, h = 150, r = 20;
  const x = width / 2 - w / 2;
  const y = height / 2 - h / 2;
  track = {
    cx: x + w / 2,
    cy: y + h / 2,
    path() {
      ctx.beginPath();
      ctx.roundRect(x, y, w, h, r);
    }
  };


  createSpan("Number of Glows");
  numberOfGlows = createSelect();
  for (let i = 1; i <= 4; i++) numberOfGlows.option(i);
  numberOfGlows.selected("2");
  numberOfGlows.changed(buildGlows);

createElement("br");
  createSpan("Glow Width");
  glowWidth = createSlider(0, 0.5, 0.15, 0.01);

createElement("br");
  createSpan("Thickness");
  thickness = createSlider(1, 10, 2, 1);

createElement("br");
  createSpan("Softness");
  softness  = createSlider(0, 1, 0.5, 0.01);

createElement("br");
  createSpan("Blur");
  blur      = createSlider(0, 30, 12, 1);

createElement("br");
  createSpan("Opacity");
  opacity   = createSlider(0, 1, 0.2, 0.1);

  buildGlows();
}

function draw() {
  background(255);

  const angle = radians(frameCount);
  const gw = glowWidth.value();
  const soft = softness.value();
  const a = opacity.value();
  const lw = thickness.value();
  const b = blur.value();

  for (const g of glows) g.draw(angle, gw, soft, a, lw, b);
}

Part 2. Typing Effect

Speed, a multiplier defaulting to 1, is determined by frames taken per letter.

We can figure out how many characters to show like this:

chars = floor(t / speed);

By flooring the number, each letter gets exactly speed frames of screen time.

For the blinking caret, I like to use combination of sin() and constrain() to get an abrupt on/off blink instead of a smooth fade.

To have the caret blink only after typing is done, I flagged the state using a variable I've called totalFrames.

let textInput, speed, restartBtn;
let resetFrame = 0;

const fontSize = 40;

function setup() {
  createCanvas(400, 400);

  createSpan("Text Input");
  textInput = createInput('Butter Studio');
  textInput.input(restart);

  createElement("br");
  createSpan("Speed");
  speed = createSlider(1, 10, 5, 1);  // frames per letter

  createElement("br");
  restartBtn = createButton('restart');
  restartBtn.mousePressed(restart);
}

function restart() {
  resetFrame = frameCount; // sets t to zero to restart animation.
}

function typedText(target, speed) {
  const totalFrames = target.length * speed;
  const t = frameCount - resetFrame;
  const chars = floor(t / speed);

  return {
    shown: target.substring(0, min(target.length, chars)),
    typing: t < totalFrames,
    done: t - totalFrames,
  };
}

function draw() {
  background(255);

  const { shown, typing, done } = typedText(textInput.value(), speed.value());

  noStroke();
  fill(0);
  textSize(fontSize);
  textAlign(LEFT, CENTER);

  const x = 24;
  const y = height / 2;
  text(shown, x, y);

  const caretAlpha = typing
    ? 255
    : constrain(sin(done * 0.1) * 500, 0, 255);

  fill(0, caretAlpha);
  rect(x + textWidth(shown), y - fontSize * 0.5, 1, fontSize);
}

Part 3. Sweep Gradient Blur

For this, you can draw a linear gradient on top of the text. Add gradient stops to make it stretch to a desired length.

const SETTLE_FRAMES = 90;
const LOOP_FRAMES = 150;
const BLUR_AMOUNT = 8;
const w = 300, h = 150, r = 20;
let color1, color2, color3;
let blurBuffer;

function setup() {
  createCanvas(400, 400);
  color1 = color("#bffa33");
  color2 = color("#03dbf6");
  color3 = color("rgba(255,255,255,0)");
  
  blurBuffer = createGraphics(w, h);
}

function draw() {
  background(255);
  const x = width / 2 - w / 2;
  const y = height / 2 - h / 2;
  const ctx = drawingContext;

  push();
  noStroke(); fill("#525252"); textSize(24); textAlign(LEFT, CENTER);
  text("Butter Studio", x + 24, y + h / 2);
  pop();

  const t = frameCount % LOOP_FRAMES;
  const p = constrain(t / SETTLE_FRAMES, 0, 1);
  const eased = 1 - pow(1 - p, 3);
  const gx = x + -w * 1.5 * (1 - eased);
  const sweepLen = w * 0.5;

  const sweep = ctx.createLinearGradient(gx, 0, gx + sweepLen, 0);
  sweep.addColorStop(0.1, color1);
  sweep.addColorStop(0.5, color2);
  sweep.addColorStop(1, color3);

  ctx.beginPath();
  ctx.roundRect(x, y, w, h, r);
  ctx.fillStyle = sweep;
  ctx.fill();
}

To create a blur on text overlapping gradient, use get() to copy the gradient area and apply blur to it. You can mask that copy with the sweep gradient itself, but its final transparent stop fades the blur out too early — so I made a separate, slightly narrower mask instead. (Note that calling get() too many times can be expensive. If you are doing this a lot or for a larger area, draw the initial content to a createGraphics() first and then draw that to both the blur graphic and the main canvas to avoid doing a copy each frame.)

const SETTLE_FRAMES = 90;
const LOOP_FRAMES = 150;
const BLUR_AMOUNT = 8;
const w = 300, h = 150, r = 20;
let color1, color2, color3;
let blurBuffer;

function setup() {
  createCanvas(400, 400);
  color1 = color("#bffa33");
  color2 = color("#03dbf6");
  color3 = color("rgba(255,255,255,0)");
  
  blurBuffer = createGraphics(w, h);
}

function draw() {
  background(255);
  const x = width / 2 - w / 2;
  const y = height / 2 - h / 2;
  const ctx = drawingContext;

  push();
  noStroke(); fill("#525252"); textSize(24); textAlign(LEFT, CENTER);
  text("Butter Studio", x + 24, y + h / 2);
  pop();

  const t = frameCount % LOOP_FRAMES;
  const p = constrain(t / SETTLE_FRAMES, 0, 1);
  const eased = 1 - pow(1 - p, 3);
  const gx = x + -w * 1.5 * (1 - eased);
  const sweepLen = w * 0.5;

  const sweep = ctx.createLinearGradient(gx, 0, gx + sweepLen, 0);
  sweep.addColorStop(0.1, color1);
  sweep.addColorStop(0.5, color2);
  sweep.addColorStop(1, color3);

  // DRAW BLUR GRAPHICS
  const capture = get(x, y, w, h);
  blurBuffer.clear();
  const bbctx = blurBuffer.drawingContext;
  bbctx.save();
  bbctx.filter = `blur(${BLUR_AMOUNT}px)`;
  blurBuffer.image(capture, 0, 0, w, h);
  bbctx.filter = "none";
  bbctx.restore();

  // MASK BLUR AREA
  bbctx.save();
  bbctx.globalCompositeOperation = "destination-in";
  const mask = bbctx.createLinearGradient(gx, 0, gx + sweepLen, 0);
  mask.addColorStop(0.5, "rgba(0,0,0,1)");
  mask.addColorStop(0.75, "rgba(0,0,0,0)");
  bbctx.fillStyle = mask;
  bbctx.fillRect(0, 0, w, h);
  bbctx.restore();

  image(blurBuffer, x, y);

  
  // DRAW GRADIENT ON TOP
  ctx.beginPath();
  ctx.roundRect(x, y, w, h, r);
  ctx.fillStyle = sweep;
  ctx.fill();
}

Part 4. Icon Buttons

For drawing icons, use the raw SVG path data. (External SVG library imports aren't reliable in Butter IDE yet, but you can still include the SVG data in your sketch!)

Constrain t (the rotation progress value) so the rotation stops at 90 degrees instead of running past it.

Use sin() and lerpColor() to simulate a smooth button-press flash on the background.

// https://flowbite.com/icons/  (24x24 viewBox)
const ICON_PLUS = "M5 12h14m-7 7V5";
const ICON_CLIP = "M7 8v8a5 5 0 1 0 10 0V6.5a3.5 3.5 0 1 0-7 0V15a2 2 0 0 0 4 0V8";
const ICON_ARROW_UP = "M12 21V3m-7 7l7-7 7 7";

const TURN_FRAMES = 60;    // duration of the turn

const iconBgColor = "#f6f6f6";
const flashColor = "#e8e8e8";
const ICON_SCALE = 0.55;

let ctx;
let resetFrame = 0;
let restartBtn;

function setup() {
  createCanvas(400, 400);
  ctx = drawingContext;

  restartBtn = createButton('restart');
  restartBtn.mousePressed(restart);
}

function restart() {
  resetFrame = frameCount;
}

function iconButton(pathStr, cx, cy, boxSize, boxRadius, bgColor, iconColor, rotation = 0, flash = 0) {
  const bg = lerpColor(color(bgColor), color(flashColor), flash);

  push();
  rectMode(CENTER);
  noStroke();
  fill(bg);
  rect(cx, cy, boxSize, boxSize, boxRadius);
  pop();

  const k = (boxSize * ICON_SCALE) / 24;

  ctx.save();
  ctx.translate(cx, cy);
  ctx.rotate(rotation);
  ctx.scale(k, k);
  ctx.translate(-12, -12);

  ctx.strokeStyle = iconColor;
  ctx.lineWidth = 2 / k;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.stroke(new Path2D(pathStr));
  ctx.restore();
}

function draw() {
  background(255);

  const f = frameCount - resetFrame;
  const t = constrain(f / TURN_FRAMES, 0, 1);
  const rotation = (1 - pow(1 - t, 3)) * HALF_PI;
  const flash = sin(t * PI);

  iconButton(ICON_PLUS,     120, height / 2, 64, 6, iconBgColor, "black");
  iconButton(ICON_CLIP,     200, height / 2, 64, 6, iconBgColor, "black");
  iconButton(ICON_ARROW_UP, 280, height / 2, 64, 6, iconBgColor, "black", rotation, flash);
}

Wrapping Up

All those steps together create the search bar component in our video!

I encourage you to not only experiment with the parameters, but also mix and match the snippets to create something that feels more like your own. Writing the code yourself can be cumbersome at first, but with practice, it becomes a powerful way to gain almost complete control over the final result!