Skip to content

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.

View source View as Markdown

DefaultDemo.vue
<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

PropTypeRequired
id
MaybeRef<string>true
sequence
DotMatrixSequencetrue
options
MagicDotMatrixOptionsfalse

Options

To customize the component, override the necessary options. Any custom options will be merged with the default options.

OptionTypeDefault
cols
number7
rows
number7
interval
number100
loop
boolean | numbertrue
autoplay
booleantrue

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.

PresetDescription
createRippleConcentric rings expanding from the center and contracting back.
createPulseA filled circle growing from the center and shrinking back.
createCometA comet with a short trail orbiting the grid’s perimeter.
createOrbitA single dot traveling along the grid’s perimeter.
createScanA horizontal line sweeping from top to bottom.
createEqualizerAudio equalizer style columns rising and falling.
createCheckerboardAlternating checkerboard fields.
createFocusRings collapsing into the center and expanding back out.
createFillDots filling the grid one by one, top to bottom.
createSpiralThe grid filling along a clockwise spiral.
createBounceA row sweeping down and back up, alternating direction.
createDriftA soft blob drifting organically around the grid.

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.

PropertyTypeDescription
stateDotMatrixStateThe component’s reactive state.
isPlayingComputedRef<boolean>Whether the sequence is currently playing.
currentFrameComputedRef<number>The index of the currently rendered frame.
play() => voidStart or resume playback.
pause() => voidPause playback.
restart() => voidReset the sequence to its first frame and start playing.

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.

OptionTypeDefault
cols
number10
rows
number10
colors
number6
threshold
number0.38

Return Values

PropertyTypeDescription
sequenceComputedRef<DotMatrixLayeredFrame[]>The animation’s frames, with one layer per color.
optionsComputedRef<MagicDotMatrixOptions>The grid size and the motion’s frame interval.
styleComputedRef<Record<string, string>>The colors as --magic-dot-matrix-color-{n} variables, plus an inactive color tinted by the most common one.
motionComputedRef<DotMatrixEmojiMotion>The name of the picked motion.

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.

VariableDefault
--magic-dot-matrix-gap
calc(100cqi / (4 * cols - 1))
--magic-dot-matrix-radius
0.33
--magic-dot-matrix-color
currentColor
--magic-dot-matrix-color-{n}
var(--magic-dot-matrix-color)
--magic-dot-matrix-inactive-color
color-mix(in srgb, currentColor 15%, transparent)
--magic-dot-matrix-backgroundtransparent
--magic-dot-matrix-transition
none

Events

The dot matrix emits the following events through MagicEmitter. Listen to them with useMagicEmitter.

EventPayloadDescription
finish{ id }Fired when the sequence has played the configured number of loops.

Examples

Presets

PresetsDemo.vue
<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

PlayPauseDemo.vue
<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

CustomColorDemo.vue
<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

EmojiDemo.vue
<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.

flicker

Open the emoji picker with ⌃ ⌘ Space on macOS or Win + . on Windows.

EmojiInputDemo.vue
<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.