MagicDotMatrix
MagicDotMatrix is a dot matrix display for playing frame based animations on a grid of dots, complete with a set of ready made sequence presets.
<template>
<div class="flex w-full items-center justify-center py-8">
<magic-dot-matrix
id="magic-dot-matrix-default-demo"
class="w-24"
:sequence="sequence"
/>
</div>
</template>
<script lang="ts" setup>
import { createRipple } from '@maas/vue-equipment/plugins/MagicDotMatrix'
const sequence = createRipple({ cols: 7, rows: 7 })
</script>Anatomy
<template>
<magic-dot-matrix id="your-dot-matrix-id" :sequence="sequence" />
</template>
<script setup>
import { createRipple } from '@maas/vue-equipment/plugins/MagicDotMatrix'
const sequence = createRipple({ cols: 7, rows: 7 })
</script>
Installation
CLI
Add @maas/vue-equipment to your dependencies.
pnpm add @maas/vue-equipment
Vue
If you are using Vue, import and add MagicDotMatrixPlugin to your app.
import { createApp } from 'vue'
import { MagicDotMatrixPlugin } from '@maas/vue-equipment/plugins/MagicDotMatrix'
const app = createApp({})
app.use(MagicDotMatrixPlugin)
Nuxt
The dot matrix is available as a Nuxt module. In your Nuxt config file add @maas/vue-equipment/nuxt to your modules and add MagicDotMatrix to the plugins in your configuration.
export default defineNuxtConfig({
modules: ['@maas/vue-equipment/nuxt'],
vueEquipment: {
plugins: ['MagicDotMatrix'],
},
})
Direct Import
If you prefer a more granular approach, the dot matrix can also be directly imported into any Vue component.
<script setup>
import { MagicDotMatrix } from '@maas/vue-equipment/plugins/MagicDotMatrix'
</script>
<template>
<magic-dot-matrix id="your-dot-matrix-id" :sequence="sequence" />
</template>
Composable
Playback is controlled through the useMagicDotMatrix composable. Import and call it with the matching id.
import { useMagicDotMatrix } from '@maas/vue-equipment/plugins/MagicDotMatrix'
const { play, pause, restart, isPlaying, currentFrame } =
useMagicDotMatrix('your-dot-matrix-id')
Peer Dependencies
MagicDotMatrix has no optional peer dependencies. It only relies on the core peer dependencies, which npm, pnpm and bun install automatically.
API Reference
Props
Options
To customize the component, override the necessary options. Any custom options will be merged with the default options.
Sequences
A sequence is an array of frames, where each frame is an array of active dot indices. Dots are indexed row by row, starting top left. Either generate sequences with one of the presets, or author them by hand.
const sequence = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
]
To light dots in more than one color, pass a frame as an array of layers. Each layer lists dot indices, and the dots of the n-th layer take their color from --magic-dot-matrix-color-{n}. If a dot appears in several layers, the last one wins.
const sequence = [
[[0, 1], [2]],
[[3, 4], [5]],
]
Presets
The plugin ships with a set of sequence generators. Each takes the grid dimensions and returns a complete sequence.
For custom sequences the underlying helpers are exported as well: toIndex, getRings, ringPath, withTrail and equalizerFrame.
useMagicDotMatrix
The composable returns the component’s state as well as functions to control playback.
useMagicDotMatrixEmoji
The emoji composable turns any emoji into an animated sequence. It picks a motion that fits the emoji, such as a swing for a bell or rain for a cloud, draws every frame onto a canvas and samples it down to the grid. Bind the returned values to the dot matrix.
<template>
<magic-dot-matrix
id="your-dot-matrix-id"
:sequence="sequence"
:options="options"
:style="style"
/>
</template>
<script setup>
import { useMagicDotMatrixEmoji } from '@maas/vue-equipment/plugins/MagicDotMatrix'
const { sequence, options, style } = useMagicDotMatrixEmoji('🔥')
</script>
Since the canvas only exists in the browser, the sequence stays empty during server-side rendering and fills in once the component has mounted. The emoji is drawn with the system’s emoji font, so the result differs between platforms.
Options
Pass the options as the second argument. Both arguments can be refs or getters, and the sequence is drawn again when they change.
Return Values
CSS Variables
The dots’ geometry and colors are styled through CSS. The component fills its container and derives the dot size from the available width, the number of columns and the gap.
Events
The dot matrix emits the following events through MagicEmitter. Listen to them with useMagicEmitter.
Examples
Presets
<template>
<div class="flex w-full flex-col items-center gap-8 py-8">
<magic-dot-matrix
id="magic-dot-matrix-presets-demo"
class="w-24"
:sequence="sequence"
/>
<div class="flex max-w-md flex-wrap justify-center gap-2">
<m-button
v-for="preset in presets"
:key="preset.name"
size="sm"
:mode="preset.name === selected ? 'solid' : 'subtle'"
@click="select(preset.name)"
>
{{ preset.name }}
</m-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { MButton } from 'mirror-compat'
import {
createCheckerboard,
createDrift,
createFocus,
createEqualizer,
createOrbit,
createComet,
createPulse,
createFill,
createRipple,
createScan,
createSpiral,
createBounce,
} from '@maas/vue-equipment/plugins/MagicDotMatrix'
const grid = { cols: 7, rows: 7 }
const presets = [
{ name: 'ripple', sequence: createRipple(grid) },
{ name: 'pulse', sequence: createPulse(grid) },
{ name: 'comet', sequence: createComet(grid) },
{ name: 'orbit', sequence: createOrbit(grid) },
{ name: 'scan', sequence: createScan(grid) },
{ name: 'equalizer', sequence: createEqualizer(grid) },
{ name: 'checkerboard', sequence: createCheckerboard(grid) },
{ name: 'focus', sequence: createFocus(grid) },
{ name: 'fill', sequence: createFill(grid) },
{ name: 'spiral', sequence: createSpiral(grid) },
{ name: 'bounce', sequence: createBounce(grid) },
{ name: 'drift', sequence: createDrift(grid) },
]
const selected = ref('ripple')
const sequence = computed(
() => presets.find((preset) => preset.name === selected.value)?.sequence ?? []
)
function select(name: string) {
selected.value = name
}
</script>
<style>
[data-id='magic-dot-matrix-presets-demo'] {
--magic-dot-matrix-radius: 0.5;
}
</style>Playback Controls
<template>
<div class="flex w-full flex-col items-center gap-8 py-8">
<magic-dot-matrix
id="magic-dot-matrix-play-pause-demo"
class="w-24"
:sequence="sequence"
:options="{ interval: 80 }"
/>
<div class="flex w-full max-w-xs gap-2">
<m-button v-if="!isPlaying" block @click="play">Play</m-button>
<m-button v-else block @click="pause">Pause</m-button>
<m-button block mode="subtle" @click="restart">Restart</m-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { MButton } from 'mirror-compat'
import {
createComet,
useMagicDotMatrix,
} from '@maas/vue-equipment/plugins/MagicDotMatrix'
const sequence = createComet({ cols: 7, rows: 7 })
const { play, pause, restart, isPlaying } = useMagicDotMatrix(
'magic-dot-matrix-play-pause-demo'
)
</script>
<style>
[data-id='magic-dot-matrix-play-pause-demo'] {
--magic-dot-matrix-radius: 0.5;
}
</style>Custom Colors
<template>
<div class="flex w-full items-center justify-center py-8">
<magic-dot-matrix
id="magic-dot-matrix-custom-color-demo"
class="w-32 rounded-xl p-4"
:sequence="sequence"
/>
</div>
</template>
<script lang="ts" setup>
import { createEqualizer } from '@maas/vue-equipment/plugins/MagicDotMatrix'
const sequence = createEqualizer({ cols: 7, rows: 7 })
</script>
<style>
[data-id='magic-dot-matrix-custom-color-demo'] {
--magic-dot-matrix-radius: 0.5;
--magic-dot-matrix-color: var(--app-color-accent-bg-solid);
--magic-dot-matrix-inactive-color: var(--app-color-accent-bg-muted);
background: oklch(0.16 0.03 264);
}
</style>Layers
<template>
<div class="grid grid-cols-2 justify-items-center gap-4 py-8 md:grid-cols-4">
<magic-dot-matrix
v-for="emoji in emojis"
:id="`magic-dot-matrix-emoji-demo-${emoji.name}`"
:key="emoji.name"
class="w-32 rounded-xl p-3"
:sequence="emoji.sequence"
:options="emoji.options"
/>
</div>
</template>
<script lang="ts" setup>
import { toIndex } from '@maas/vue-equipment/plugins/MagicDotMatrix'
import type { DotMatrixLayeredFrame } from '@maas/vue-equipment/plugins/MagicDotMatrix'
type Art = Array<string>
type Point = [col: number, row: number]
type PaintArgs = {
base: Art
top: Art
offset?: Point
}
type PlotArgs = {
points: Array<Point>
layer: number
}
type SpanArgs = {
from: number
to: number
row: number
}
type HoldArgs = {
art: Art
count: number
}
type EmberArgs = {
flame: Art
ember: Point
}
type UnderlinesArgs = {
from: number
to: number
}
type ShineArgs = {
art: Art
diagonal: number
}
const size = 10
function blank(): Art {
return Array.from({ length: size }, () => '.'.repeat(size))
}
function paint({ base, top, offset = [0, 0] }: PaintArgs): Art {
const [x, y] = offset
return base.map((line, row) =>
[...line]
.map((char, col) => {
const source = top[row - y]?.[col - x] ?? '.'
return source === '.' ? char : source
})
.join('')
)
}
function plot({ points, layer }: PlotArgs): Art {
return blank().map((line, row) =>
[...line]
.map((char, col) =>
points.some(([x, y]) => x === col && y === row) ? `${layer}` : char
)
.join('')
)
}
function span({ from, to, row }: SpanArgs): Array<Point> {
return Array.from({ length: Math.max(0, to - from + 1) }, (_, index) => [
from + index,
row,
])
}
function hold({ art, count }: HoldArgs): Array<Art> {
return Array.from({ length: count }, () => art)
}
function toFrame(art: Art): DotMatrixLayeredFrame {
const layers: DotMatrixLayeredFrame = Array.from({ length: 9 }, () => [])
art.forEach((line, row) => {
;[...line].forEach((char, col) => {
layers[Number(char) - 1]?.push(toIndex({ col, row, cols: size }))
})
})
return layers
}
const flameCalm: Art = [
'....1.....',
'...11.....',
'...111....',
'..1111..1.',
'..11211.1.',
'.1112211..',
'.11223211.',
'.12233221.',
'.12333321.',
'..122221..',
]
const flameRising: Art = [
'.....1....',
'....11....',
'....111...',
'...1121...',
'.1.11221..',
'.11122211.',
'.11223211.',
'.12233221.',
'.12333321.',
'..122221..',
]
const flameFlaring: Art = [
'...1......',
'...11.....',
'..111...1.',
'..1111..1.',
'..11211.11',
'.11122111.',
'.11223211.',
'.12233221.',
'.12333321.',
'..122221..',
]
function withEmber({ flame, ember }: EmberArgs): Art {
return paint({ base: flame, top: plot({ points: [ember], layer: 2 }) })
}
const fire = [
flameCalm,
withEmber({ flame: flameRising, ember: [8, 2] }),
withEmber({ flame: flameFlaring, ember: [6, 1] }),
withEmber({ flame: flameRising, ember: [7, 0] }),
flameCalm,
flameFlaring,
]
const digits: Art = [
'..........',
'.1..1...1.',
'11.1.1.1.1',
'.1.1.1.1.1',
'.1.1.1.1.1',
'.1..1...1.',
'..........',
'..........',
'..........',
'..........',
]
function underlines({ from, to }: UnderlinesArgs): Art {
const upper = plot({
points: span({ from: Math.max(0, from), to: Math.min(9, to), row: 7 }),
layer: 1,
})
const lower = plot({
points: span({
from: Math.max(2, from - 2),
to: Math.min(9, to - 2),
row: 9,
}),
layer: 1,
})
return paint({ base: paint({ base: digits, top: upper }), top: lower })
}
function shine({ art, diagonal }: ShineArgs): Art {
return art.map((line, row) =>
[...line]
.map((char, col) => {
const distance = col + row - diagonal
return char !== '.' && distance >= 0 && distance <= 1 ? '2' : char
})
.join('')
)
}
const underlined = underlines({ from: 0, to: 11 })
const hundred = [
...[1, 3, 5, 7, 9, 11].map((to) => underlines({ from: 0, to })),
...Array.from({ length: 18 }, (_, index) =>
shine({ art: underlined, diagonal: index })
),
...hold({ art: underlined, count: 6 }),
...[2, 4, 6, 8, 10, 12].map((from) => underlines({ from, to: 11 })),
...hold({ art: digits, count: 2 }),
]
const ship: Art = [
'........22',
'.......222',
'......1112',
'.....1331.',
'....1331..',
'...11311..',
'..21111...',
'.22111....',
'.2212.....',
'...22.....',
]
const exhausts: Array<Art> = [['54'], ['45'], ['.5'], ['45'], ['55']]
const stars = [
{ diagonal: 2, phase: 0 },
{ diagonal: 5, phase: 6 },
{ diagonal: 16, phase: 3 },
]
const rocket = Array.from({ length: size }, (_, tick) => {
const field = plot({
points: stars.map(({ diagonal, phase }): Point => {
const col = Math.min(size - 1, diagonal) - ((tick + phase) % size)
return [col, diagonal - col]
}),
layer: 6,
})
const flying = paint({
base: ship,
top: exhausts[tick % exhausts.length] ?? [],
offset: [0, 9],
})
return paint({ base: field, top: flying })
})
const hand: Art = [
'...11.....',
'...11.....',
'..1112....',
'.11111111.',
'.11112222.',
'.11111111.',
'.11112222.',
'.11111111.',
'..111122..',
]
const sparkle = plot({
points: [
[8, 0],
[7, 1],
[9, 1],
],
layer: 3,
})
const resting = paint({ base: blank(), top: hand, offset: [0, 1] })
const raised = paint({
base: paint({ base: blank(), top: hand }),
top: sparkle,
})
const thumbsUp = [
...hold({ art: resting, count: 6 }),
...hold({ art: raised, count: 2 }),
resting,
...hold({ art: raised, count: 2 }),
...hold({ art: resting, count: 4 }),
]
const emojis = [
{ name: 'fire', art: fire, interval: 110 },
{ name: 'hundred', art: hundred, interval: 70 },
{ name: 'rocket', art: rocket, interval: 90 },
{ name: 'thumbs-up', art: thumbsUp, interval: 90 },
].map(({ name, art, interval }) => ({
name,
sequence: art.map(toFrame),
options: { cols: size, rows: size, interval },
}))
</script>
<style>
[data-id^='magic-dot-matrix-emoji-demo'] {
--magic-dot-matrix-radius: 0.5;
background: #000;
}
[data-id='magic-dot-matrix-emoji-demo-fire'] {
--magic-dot-matrix-inactive-color: #2a1712;
--magic-dot-matrix-color-1: #f2612c;
--magic-dot-matrix-color-2: #fbc23f;
--magic-dot-matrix-color-3: #fff4d6;
}
[data-id='magic-dot-matrix-emoji-demo-hundred'] {
--magic-dot-matrix-inactive-color: #2a1214;
--magic-dot-matrix-color-1: #ef3b3b;
--magic-dot-matrix-color-2: #ffe3e3;
}
[data-id='magic-dot-matrix-emoji-demo-rocket'] {
--magic-dot-matrix-inactive-color: #141729;
--magic-dot-matrix-color-1: #e9edf3;
--magic-dot-matrix-color-2: #e8414a;
--magic-dot-matrix-color-3: #4aa8f0;
--magic-dot-matrix-color-4: #f57b2a;
--magic-dot-matrix-color-5: #ffd24a;
--magic-dot-matrix-color-6: #5a5f8c;
}
[data-id='magic-dot-matrix-emoji-demo-thumbs-up'] {
--magic-dot-matrix-inactive-color: #231a14;
--magic-dot-matrix-color-1: #c99268;
--magic-dot-matrix-color-2: #7a4a2c;
--magic-dot-matrix-color-3: #ffd24a;
}
</style>Emoji
The example passes the emoji in the input to useMagicDotMatrixEmoji. The badge shows the motion it picked.
Open the emoji picker with ⌃ ⌘ Space on macOS or Win + . on Windows.
<template>
<div class="flex w-full flex-col items-center gap-6 py-8">
<magic-dot-matrix
id="magic-dot-matrix-emoji-input-demo"
class="w-40 rounded-xl p-4"
:style="style"
:sequence="sequence"
:options="options"
/>
<m-badge size="sm" mode="subtle">{{ motion }}</m-badge>
<div class="flex w-full max-w-xs flex-col gap-3">
<m-input :model-value="emoji" label="Emoji" @input="onInput" />
<p class="type-surface-caption text-surface-muted text-center">
Open the emoji picker with
<span class="whitespace-nowrap">⌃ ⌘ Space</span> on macOS or
<span class="whitespace-nowrap">Win + .</span> on Windows.
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { MBadge, MInput } from 'mirror-compat'
import { useMagicDotMatrixEmoji } from '@maas/vue-equipment/plugins/MagicDotMatrix'
const emoji = ref('🔥')
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
const pictographic =
/\p{Extended_Pictographic}|\p{Regional_Indicator}|\u{20E3}/u
function onInput(event: Event) {
if (!(event.target instanceof HTMLInputElement)) {
return
}
const emojis = [...segmenter.segment(event.target.value)]
.map(({ segment }) => segment)
.filter((segment) => pictographic.test(segment))
emoji.value = emojis.find((entry) => entry !== emoji.value) ?? emojis[0] ?? ''
event.target.value = emoji.value
}
const { sequence, options, style, motion } = useMagicDotMatrixEmoji(emoji)
</script>
<style>
[data-id='magic-dot-matrix-emoji-input-demo'] {
--magic-dot-matrix-radius: 0.5;
background: #000;
}
</style>Inspiration
MagicDotMatrix is inspired by Dot Matrix.