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/banderitasControls
<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
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/banderitas - 3Use it:
import { Banderitas } from "@/components/vibebox/banderitas"; <section> <Banderitas rows={2} /> <h2>Fiesta weekend</h2> </section>
Code
2 filescomponents/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
| Prop | Type | Default | Description |
|---|---|---|---|
| height | number | 140 | Height of the band in pixels. |
| rows | 1 | 2 | 3 | 2 | Strings of flags. |
| shape | "pennant" | "banner" | "pennant" | Triangle pennants, or cut-paper banners with little holes. |
| colors | readonly string[] | — | Flag colours (any CSS colour), in order; defaults to the seven spectrum tokens. |
| sag | number | 0.6 | How much each string sags, 0 (taut) to 1 (deep). |
| spacing | number | 54 | Pixels between flags. |
| interactive | boolean | true | The pointer and scrolling move the flags. |
| className | string | — | 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
spacingto keep the line airy. - It is decoration only: never put information in the flag colours.
Related

Parol
ProA 3D parol, the Filipino Christmas lantern: capiz panels, a ring of petals, tassel tails and chasing lights.
Lokal · three.js