Skip to content
Motion
FreeIn reviewv1.0.0

Banderitas

Fiesta bunting strung across a section: pennants or cut-paper banners that sway with the breeze, the cursor and scroll.

npx shadcn@latest add @vibebox/banderitas

Controls

Flags
2
0.6
54
<Banderitas />

About

The flags of a Philippine fiesta, over any section. One to three strings sag across the band; on them hang triangular pennants or cut-paper banners with a scalloped hem and little holes. Each flag swings like a damped pendulum: a soft breeze keeps the line alive, the pointer brushing through pushes the flags it passes, and scrolling gives the whole line a tug. Colours come from your spectrum tokens, or a palette you pass. It draws on a Canvas 2D on the shared engine ticker, sleeps when offscreen, and is a still drawing under reduced motion. Original to VibeBox Motion; part of Lokal, pieces drawn from Philippine craft and celebration.

Use it when

  • A barangay fiesta, school fair, food stall, market or event page wants festive cheer.
  • A seasonal banner (town fiesta, Independence Day, Christmas bazaar) needs a light touch.

Skip it when

  • The page is serious or formal (legal, medical results).
  • Space is tight on phones; keep it to one string there.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/banderitas
  3. 3Use it:
    import { Banderitas } from "@/components/vibebox/banderitas";
    
    <section>
      <Banderitas rows={2} />
      <h2>Fiesta weekend</h2>
    </section>

Code

2 files
components/vibebox/banderitas/banderitas.client.tsx
/**
 * VibeBox Motion · Banderitas
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { useEffect, useRef } from "react";
import { prefersReducedMotion, subscribeTicker } from "@/lib/vibebox/engine/core";
import { cn } from "@/lib/utils";

/*
 * Fiesta bunting strung across a section. Each flag hangs from a sagging string and swings like
 * a damped pendulum: a soft breeze keeps it alive, the pointer brushing past pushes it, and
 * scrolling gives the whole line a tug. Canvas 2D on the shared engine ticker; it sleeps
 * offscreen and is a still drawing under reduced motion.
 */

export type BanderitasProps = {
  /** Height of the band in pixels. */
  height?: number;
  /** Strings of flags, one to three. */
  rows?: 1 | 2 | 3;
  /** Triangle pennants, or cut-paper banners with little holes. */
  shape?: "pennant" | "banner";
  /** Flag colours (any CSS colour), in order; defaults to the seven spectrum tokens. */
  colors?: readonly string[];
  /** How much each string sags, 0 (taut) to 1 (deep). */
  sag?: number;
  /** Pixels between flags. */
  spacing?: number;
  /** The pointer and scrolling move the flags. */
  interactive?: boolean;
  className?: string;
};

type Flag = { x: number; row: number; color: string; angle: number; spin: number; seed: number };

export function Banderitas({ height = 140, rows = 2, shape = "pennant", colors, sag = 0.6, spacing = 54, interactive = true, className }: BanderitasProps) {
  const hostRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const colorKey = (colors ?? []).join("|");

  useEffect(() => {
    const host = hostRef.current;
    const canvas = canvasRef.current;
    const context = canvas?.getContext("2d");
    if (!host || !canvas || !context) return;
    const still = prefersReducedMotion();
    let width = 0;
    let flags: Flag[] = [];
    let string = "#888";
    let visible = false;
    let time = 0;
    const pointer = { x: -1e4, y: -1e4, vx: 0, lastX: 0, lastT: 0 };

    const readColors = () => {
      const style = getComputedStyle(host);
      const palette = colors?.length ? [...colors] : [1, 2, 3, 4, 5, 6, 7].map((index) => style.getPropertyValue(`--spectrum-${index}`).trim() || "#888");
      string = style.getPropertyValue("--border-strong").trim() || style.color;
      return palette;
    };

    const rowY = (row: number) => 6 + row * (height * 0.34);
    const stringY = (row: number, x: number) => rowY(row) + sag * height * 0.42 * 4 * (x / width) * (1 - x / width) * (row === 0 ? 1 : 0.8);

    const layout = () => {
      const rect = host.getBoundingClientRect();
      width = rect.width;
      const ratio = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.round(width * ratio));
      canvas.height = Math.max(1, Math.round(height * ratio));
      context.setTransform(ratio, 0, 0, ratio, 0, 0);
      const palette = readColors();
      flags = [];
      for (let row = 0; row < rows; row++) {
        const count = Math.max(4, Math.round(width / spacing));
        const offset = row % 2 ? 0.5 : 0;
        for (let index = 0; index < count; index++) {
          const x = ((index + 0.5 + offset) / (count + offset)) * width;
          flags.push({ x, row, color: palette[(index + row * 3) % palette.length]!, angle: 0, spin: 0, seed: index * 12.9898 + row * 78.233 });
        }
      }
      draw();
    };

    const draw = () => {
      context.clearRect(0, 0, width, height);
      context.lineWidth = 1.2;
      context.strokeStyle = string;
      for (let row = 0; row < rows; row++) {
        context.beginPath();
        for (let x = 0; x <= width; x += 8) context.lineTo(x, stringY(row, x));
        context.stroke();
      }
      // Flags take two thirds of their slot, so the string shows between them.
      const size = spacing * 0.64;
      for (const flag of flags) {
        const y = stringY(flag.row, flag.x);
        context.save();
        context.translate(flag.x, y);
        context.rotate(flag.angle);
        // A flag turning edge-on looks narrower: a cheap hint of depth.
        context.scale(1 - Math.min(0.45, Math.abs(flag.angle) * 0.9), 1);
        context.fillStyle = flag.color;
        context.beginPath();
        if (shape === "pennant") {
          context.moveTo(-size / 2, 0);
          context.lineTo(size / 2, 0);
          context.lineTo(0, size * 1.12);
          context.closePath();
          context.fill();
        } else {
          const w = size * 0.92;
          const h = size * 1.15;
          context.moveTo(-w / 2, 0);
          context.lineTo(w / 2, 0);
          context.lineTo(w / 2, h * 0.86);
          // A scalloped hem, then three cut-outs (filled even-odd, so they are holes).
          for (let i = 0; i < 4; i++) {
            const x0 = w / 2 - (i * w) / 4;
            context.quadraticCurveTo(x0 - w / 8, h, x0 - w / 4, h * 0.86);
          }
          context.closePath();
          for (const [cx, cy, r] of [
            [0, h * 0.32, w * 0.13],
            [-w * 0.24, h * 0.56, w * 0.08],
            [w * 0.24, h * 0.56, w * 0.08],
          ] as const) {
            context.moveTo(cx + r, cy);
            context.arc(cx, cy, r, 0, Math.PI * 2);
          }
          context.fill("evenodd");
        }
        context.fillStyle = "rgb(255 255 255 / 0.14)";
        context.fillRect(-size / 2, 0, size, 2);
        context.restore();
      }
    };

    const step = (delta: number) => {
      if (!visible) return false;
      time += delta;
      let energy = 0;
      for (const flag of flags) {
        const y = stringY(flag.row, flag.x) + spacing * 0.4;
        const breeze = Math.sin(time * 1.3 + flag.x * 0.012 + flag.row * 1.7) * 0.6 + Math.sin(time * 2.9 + flag.seed) * 0.25;
        let push = breeze * 0.9;
        if (interactive) {
          const distance = Math.hypot(pointer.x - flag.x, pointer.y - y);
          push += pointer.vx * 0.012 * Math.exp(-distance / 70);
        }
        flag.spin += (push - flag.angle * 28 - flag.spin * 3.2) * delta;
        flag.angle += flag.spin * delta;
        flag.angle = Math.max(-0.9, Math.min(0.9, flag.angle));
        energy += Math.abs(flag.spin);
      }
      pointer.vx *= Math.exp(-6 * delta);
      draw();
      return energy > 0.0001;
    };

    layout();
    // Size and theme changes redraw in every mode; only motion waits for the ticker.
    const resize = new ResizeObserver(() => layout());
    resize.observe(host);
    const theme = new MutationObserver(() => layout());
    theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
    if (still) {
      return () => {
        resize.disconnect();
        theme.disconnect();
      };
    }
    const ticker = subscribeTicker(step);
    const observer = new IntersectionObserver(([entry]) => {
      visible = Boolean(entry?.isIntersecting);
      if (visible) ticker.wake();
    });
    observer.observe(host);
    const onMove = (event: PointerEvent) => {
      const rect = host.getBoundingClientRect();
      const now = performance.now();
      const dt = Math.max(16, now - pointer.lastT) / 1000;
      pointer.vx = Math.max(-2400, Math.min(2400, (event.clientX - pointer.lastX) / dt));
      pointer.lastX = event.clientX;
      pointer.lastT = now;
      pointer.x = event.clientX - rect.left;
      pointer.y = event.clientY - rect.top;
      ticker.wake();
    };
    let lastScroll = window.scrollY;
    const onScroll = () => {
      const dy = window.scrollY - lastScroll;
      lastScroll = window.scrollY;
      for (const flag of flags) flag.spin += dy * 0.004 * (flag.seed % 2 > 1 ? 1 : -1);
      ticker.wake();
    };
    if (interactive) {
      host.addEventListener("pointermove", onMove, { passive: true });
      window.addEventListener("scroll", onScroll, { passive: true });
    }
    return () => {
      ticker.stop();
      observer.disconnect();
      resize.disconnect();
      theme.disconnect();
      host.removeEventListener("pointermove", onMove);
      window.removeEventListener("scroll", onScroll);
    };
    // colorKey stands in for `colors`, so a new array with the same colours does not restart it.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [height, rows, shape, colorKey, sag, spacing, interactive]);

  return (
    <div ref={hostRef} data-vb="banderitas" aria-hidden="true" className={cn("relative w-full overflow-hidden", className)} style={{ height }}>
      <canvas ref={canvasRef} className="absolute inset-0 size-full" />
    </div>
  );
}
components/vibebox/banderitas/index.tsx
/**
 * VibeBox Motion · Banderitas
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { Banderitas, type BanderitasProps } from "./banderitas.client";

Props

PropTypeDefaultDescription
heightnumber140Height of the band in pixels.
rows1 | 2 | 32Strings of flags.
shape"pennant" | "banner""pennant"Triangle pennants, or cut-paper banners with little holes.
colorsreadonly string[]—Flag colours (any CSS colour), in order; defaults to the seven spectrum tokens.
sagnumber0.6How much each string sags, 0 (taut) to 1 (deep).
spacingnumber54Pixels between flags.
interactivebooleantrueThe pointer and scrolling move the flags.
classNamestring—Merged with cn().

Accessibility and motion

Keyboard
Not focusable; it is decoration.
Screen readers
Hidden (aria-hidden); it carries no information.
Touch
Scrolling tugs the flags; touch never blocks scrolling.
Reduced motion
A still drawing of the bunting; no breeze, pointer or scroll movement.

Edge cases and limits

  • Very wide screens hang many flags; raise spacing to keep the line airy.
  • It is decoration only: never put information in the flag colours.
  • A glowing capiz parol with a ring of petals, tassel tails and coloured lights

    A 3D parol, the Filipino Christmas lantern: capiz panels, a ring of petals, tassel tails and chasing lights.

    Lokal · three.js

↑ ↓ to move↵ to openesc to close