Switchboard Card
A feature card whose background is an animated LED light grid that lights dots to spell a short word, then cycles between a few preset patterns on a timer. Built from a tiny 5-row dot font; respects reduced-motion by rendering the word statically. Composes card and (optional) button primitives.
Preview
<x-ui.switchboard-card class="mx-auto w-full max-w-sm" word="BROK" href="#" />
Installation
php artisan ui:add switchboard-card
Note
This component ships an Alpine behavior module at
resources/js/ui/switchboard-card.js. Import it once from your bundle so it registers on alpine:init:
import './switchboard-card.js';
Registry contract
php artisan ui:add switchboard-card
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/switchboard-card.blade.php -
resources/js/ui/switchboard-card.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Switchboard Card (`switchboard-card`)
A feature card whose background is an animated LED light grid that lights dots to spell a short word, then cycles between a few preset patterns on a timer. Built from a tiny 5-row dot font; respects reduced-motion by rendering the word statically. Composes card and (optional) button primitives.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add switchboard-card
```
## Usage
```blade
<x-ui.switchboard-card class="mx-auto w-full max-w-sm" word="BROK" href="#" />
```
## Props
- `title` (string, default `Switchboard`)
- `description` (string, default `A live LED grid that spells the word, then cycles through patterns.`)
- `word` (string, default `HI`)
- `href` (mixed|null, default `null`)
## Use when
- Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
- Giving a feature card a playful animated backdrop that spells a short word.
- Decorating a hero or bento tile with an LED grid.
## Avoid when
- Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
- Conveying the word as content: the grid is decorative and the title carries the message.
- Dense dashboards, where an always-animating tile competes with data.
## Anti-patterns
- Using cards for dense cross-item comparison
## Rules
- Use the `<brok:switchboard-card>` tag (or `<x-ui.switchboard-card>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/switchboard-card
- Registry JSON (files, props, contract): https://brokui.dev/r/open/switchboard-card.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | Switchboard | |
| description | string | A live LED grid that spells the word, then cycle... | |
| word | string | HI | |
| href | mixed | null | null |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- word is drawn from a 5-row dot font, then the grid cycles through preset patterns on a timer.
- href adds a button primitive as the call to action.
- Under prefers-reduced-motion the word renders once, statically.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Present a self-contained content object with local context.
Use when
- Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
- Giving a feature card a playful animated backdrop that spells a short word.
- Decorating a hero or bento tile with an LED grid.
Avoid when
- Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
- Conveying the word as content: the grid is decorative and the title carries the message.
- Dense dashboards, where an always-animating tile competes with data.
Use instead
- List for compact scanning
- Table for exact comparison
Anti-patterns
- Using cards for dense cross-item comparison
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The grid is aria-hidden; the title and description are the announced content.
- The optional action is a real button with the link semantics of the button primitive.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="switchboard-card-{{ $record->id }}">
<x-ui.switchboard-card class="mx-auto w-full max-w-sm" word="BROK" href="#" />
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'title' => 'Switchboard',
'description' => 'A live LED grid that spells the word, then cycles through patterns.',
'word' => 'HI',
'href' => null,
])
@php
$title = (string) $title;
$description = (string) $description;
// Keep the grid legible: a short, uppercase word.
$word = strtoupper((string) $word);
$href = $href !== null ? (string) $href : null;
// Fixed opacity classes drive the three LED brightness levels. They are listed
// literally here (never spliced) so the Tailwind JIT scanner can see them and so
// the Alpine state only has to pick an index — 0 = off, 1 = dim, 2 = bright.
$ledLevels = ['bg-foreground/10', 'bg-foreground/40', 'bg-primary'];
// Enough columns for the word (5 per glyph plus a gap), never fewer than 15.
$cols = max(15, mb_strlen($word) * 6 - 1);
@endphp
{{--
Switchboard Card — a feature card headed by an animated LED dot grid. A
tiny 5-row uppercase font (defined in switchboard-card.js) lights dots to
spell `word`, then the control cycles through that word and a couple of
preset shapes on a ~2.5s interval. The interval is cleared in destroy() so
unmounting can't leak it. Under prefers-reduced-motion the word renders
statically with no cycling. The grid is its own panel above the copy (never
behind it, where dots and letters fought for the same pixels) and is
decorative (aria-hidden). Rows/cols use logical flow so it mirrors under
dir="rtl".
--}}
<x-ui.card
data-slot="switchboard-card"
x-data="uiSwitchboardCard({ word: {{ \Illuminate\Support\Js::from($word) }}, cols: {{ (int) $cols }}, rows: 7, levels: {{ \Illuminate\Support\Js::from($ledLevels) }} })"
{{ $attributes->merge(['class' => 'overflow-hidden']) }}
>
{{-- The LED panel: a muted board with a dot per cell. --}}
<div
data-slot="switchboard-card-grid"
aria-hidden="true"
class="border-b border-border bg-muted/40 px-6 py-5"
>
<div
class="mx-auto grid w-full max-w-sm gap-1"
x-bind:style="`grid-template-columns: repeat(${cols}, minmax(0, 1fr))`"
>
<template x-for="(cell, i) in cells" :key="i">
<span
class="aspect-square rounded-full transition-colors duration-500 ease-out motion-reduce:transition-none"
x-bind:class="levels[cell]"
></span>
</template>
</div>
</div>
<div data-slot="switchboard-card-content" class="flex flex-col gap-2 p-6">
<h3 class="text-base font-semibold text-foreground">{{ __($title) }}</h3>
<p class="text-sm text-muted-foreground">{{ __($description) }}</p>
@if ($href)
<div class="mt-2">
<x-ui.button :href="$href" variant="outline" size="sm" :withArrow="true">
{{ __('Learn more') }}
</x-ui.button>
</div>
@endif
</div>
</x-ui.card>
/**
* Switchboard Card behavior — an animated LED dot-grid background.
*
* The grid is a `rows × cols` array of brightness indices (0 = off, 1 = dim,
* 2 = bright) exposed as a flat `cells` list the Blade `x-for` maps to fixed
* opacity classes (passed in as `levels`, never spliced into a class). A tiny
* 5-row uppercase font (`FONT`) renders `word` into the grid; the control then
* cycles through [the word, a couple of preset shapes, a random sprinkle] on a
* ~2.5s `setInterval`.
*
* The interval is the only timer; it is ALWAYS cleared in `destroy()` so an
* unmount mid-cycle can't leak it. Under `prefers-reduced-motion: reduce` the
* word is rendered once and no interval is started.
*
* Self-registers on `alpine:init` so import order doesn't matter — import this
* file once from your bundle (e.g. resources/js/ui/index.js).
*/
// 5-row uppercase font, defined as rows (easier to read/edit than columns).
// Each glyph is 5 rows of N chars; '#' lights the dot. Unknown chars fall back
// to '?'. The grid renderer transposes these rows into lit columns.
const ROWS = {
A: ['.###.', '#...#', '#####', '#...#', '#...#'],
B: ['####.', '#...#', '####.', '#...#', '####.'],
C: ['.####', '#....', '#....', '#....', '.####'],
D: ['####.', '#...#', '#...#', '#...#', '####.'],
E: ['#####', '#....', '###..', '#....', '#####'],
F: ['#####', '#....', '###..', '#....', '#....'],
G: ['.####', '#....', '#.###', '#...#', '.####'],
H: ['#...#', '#...#', '#####', '#...#', '#...#'],
I: ['#####', '..#..', '..#..', '..#..', '#####'],
J: ['..###', '...#.', '...#.', '#..#.', '.##..'],
K: ['#...#', '#..#.', '###..', '#..#.', '#...#'],
L: ['#....', '#....', '#....', '#....', '#####'],
M: ['#...#', '##.##', '#.#.#', '#...#', '#...#'],
N: ['#...#', '##..#', '#.#.#', '#..##', '#...#'],
O: ['.###.', '#...#', '#...#', '#...#', '.###.'],
P: ['####.', '#...#', '####.', '#....', '#....'],
Q: ['.###.', '#...#', '#.#.#', '#..#.', '.##.#'],
R: ['####.', '#...#', '####.', '#..#.', '#...#'],
S: ['.####', '#....', '.###.', '....#', '####.'],
T: ['#####', '..#..', '..#..', '..#..', '..#..'],
U: ['#...#', '#...#', '#...#', '#...#', '.###.'],
V: ['#...#', '#...#', '#...#', '.#.#.', '..#..'],
W: ['#...#', '#...#', '#.#.#', '##.##', '#...#'],
X: ['#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
Y: ['#...#', '.#.#.', '..#..', '..#..', '..#..'],
Z: ['#####', '...#.', '..#..', '.#...', '#####'],
'0': ['.###.', '#..##', '#.#.#', '##..#', '.###.'],
'1': ['..#..', '.##..', '..#..', '..#..', '.###.'],
'2': ['.###.', '#...#', '..##.', '.#...', '#####'],
'3': ['####.', '....#', '.###.', '....#', '####.'],
'4': ['#..#.', '#..#.', '#####', '...#.', '...#.'],
'5': ['#####', '#....', '####.', '....#', '####.'],
'6': ['.###.', '#....', '####.', '#...#', '.###.'],
'7': ['#####', '....#', '...#.', '..#..', '.#...'],
'8': ['.###.', '#...#', '.###.', '#...#', '.###.'],
'9': ['.###.', '#...#', '.####', '....#', '.###.'],
' ': ['.....', '.....', '.....', '.....', '.....'],
'!': ['..#..', '..#..', '..#..', '.....', '..#..'],
'?': ['.###.', '#...#', '..##.', '.....', '..#..'],
};
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiSwitchboardCard', (config = {}) => ({
rows: config.rows ?? 5,
cols: config.cols ?? 13,
word: String(config.word ?? 'HI').toUpperCase(),
levels: Array.isArray(config.levels) ? config.levels : ['off', 'dim', 'bright'],
/** Flat row-major list of brightness indices the template renders. */
cells: [],
_timer: null,
_patternIndex: 0,
init() {
this.cells = this.blank();
const patterns = this.patterns();
this.cells = patterns[0]();
if (this.reduceMotion) return;
this._timer = setInterval(() => {
this._patternIndex = (this._patternIndex + 1) % patterns.length;
this.cells = patterns[this._patternIndex]();
}, 2500);
},
destroy() {
// The interval is the only timer — always clear it on unmount.
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
},
get reduceMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
},
/** An all-off grid. */
blank() {
return new Array(this.rows * this.cols).fill(0);
},
/** The ordered set of pattern producers the interval cycles through. */
patterns() {
return [() => this.renderWord(this.word), () => this.checker(), () => this.frame(), () => this.sprinkle()];
},
/** Render `word` centered in the grid using the 5-row font. */
renderWord(word) {
const grid = this.blank();
// Build the lit columns for the whole word with 1 blank column between glyphs.
const wordCols = [];
for (const ch of word) {
const glyph = ROWS[ch] ?? ROWS['?'];
const width = glyph[0].length;
for (let c = 0; c < width; c++) {
const col = [];
for (let r = 0; r < 5; r++) col.push(glyph[r][c] === '#');
wordCols.push(col);
}
wordCols.push([false, false, false, false, false]); // gap column
}
if (wordCols.length) wordCols.pop(); // trim trailing gap
// Center horizontally and vertically (font is 5 rows tall).
const startCol = Math.max(0, Math.floor((this.cols - wordCols.length) / 2));
const rowOffset = Math.max(0, Math.floor((this.rows - 5) / 2));
wordCols.forEach((col, ci) => {
const x = startCol + ci;
if (x < 0 || x >= this.cols) return;
col.forEach((lit, ri) => {
const y = rowOffset + ri;
if (y < 0 || y >= this.rows) return;
grid[y * this.cols + x] = lit ? 2 : 0;
});
});
return grid;
},
/** Preset shape: a dim checkerboard. */
checker() {
const grid = this.blank();
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.cols; x++) {
grid[y * this.cols + x] = (x + y) % 2 === 0 ? 1 : 0;
}
}
return grid;
},
/** Preset shape: a bright border frame with a dim interior. */
frame() {
const grid = this.blank();
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.cols; x++) {
const edge = x === 0 || y === 0 || x === this.cols - 1 || y === this.rows - 1;
grid[y * this.cols + x] = edge ? 2 : 1;
}
}
return grid;
},
/** Random sprinkle across the three brightness levels. */
sprinkle() {
const grid = this.blank();
for (let i = 0; i < grid.length; i++) {
const roll = Math.random();
grid[i] = roll > 0.8 ? 2 : roll > 0.5 ? 1 : 0;
}
return grid;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0