Skip to content
Brok UI

Loading…

No results

Switchboard Card

Open source

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.

Version
v1.1.2
Stability
stable
License
MIT
Related
Apple Hello Effect
Matrix Rain
Expandable Card

Preview

Switchboard

A live LED grid that spells the word, then cycles through patterns.

previews.components.switchboard-card.default.blade.php Blade
<x-ui.switchboard-card class="mx-auto w-full max-w-sm" word="BROK" href="#" />

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/switchboard-card.blade.php
  • js resources/js/ui/switchboard-card.js
Registry dependencies
card button
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

switchboard-card.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

switchboard-card switchboard-card-content switchboard-card-grid

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

Card and content widget

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
root switchboard-card-grid switchboard-card-content
Theming hooks
switchboard-card switchboard-card-content switchboard-card-grid

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/switchboard-card.blade.php Blade
@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>
resources/js/ui/switchboard-card.js JS
/**
 * 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