Esc

comark-magic-move

A code group whose tabs are steps, the tokens two steps share sliding to their new places.

Playground
Fetch
export async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
}
ts
Handle errors
export async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error(`User ${id}: ${res.status}`)
  return res.json()
}
ts
Cache
const cache = new Map<string, Promise<User>>()

export function getUser(id: string) {
  if (!cache.has(id)) cache.set(id, load(id))
  return cache.get(id)!
}

async function load(id: string) {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error(`User ${id}: ${res.status}`)
  return res.json() as Promise<User>
}
ts
  • Every code block inside ::magic-move is one step, with a tab named by the label after its language.
  • The words and marks two steps share move to their new places, and the rest fade in or out.
  • It reads the rendered code, so it works with Shiki or any other highlighter, or none.
  • Without the script, the steps read one after another, each under its label.
  • With reduced motion, the step changes without moving anything.

Write two or more code blocks inside ::magic-move:

Playground
Step 1
const total = price * 2
ts
Step 2
const total = price * rate
ts

Use the [label] after a fence's language to name its step's tab. A step without one is named by its position, such as Step 2.

Playground
Start
const total = price * 2
ts
Use the rate
const total = price * rate
ts

Use the title prop to name the tabs for screen readers. The page doesn't show it.

::magic-move{title="Extract the rate"}
md

Code two steps share moves, and code that changes fades. Change one thing per step so the reader can follow each move.

Playground
One argument
greet(name)
ts
Two arguments
greet(name, { formal: true })
ts

An animation shows one step at a time, so a reviewer can't see all the removed and added lines at once. For a change someone approves, write a ::diff.

The steps follow the WAI-ARIA tabs pattern. The tabs are a tab list, named by the block's title, and each step is a tab panel named by its tab. Only the open tab is a tab stop, so Tab moves from it to the open step and then on.

When the reader prefers reduced motion, a tab opens its step without moving anything.

Key Description
ArrowLeft, ArrowRight On a tab, opens the previous or next step, wrapping at either end. On a right-to-left page they follow the reading direction.
Home, End On a tab, opens the first or last step.
Tab Moves focus from the open tab to its step.
title

The name screen readers read for the tabs.

[label]

Default: Step 1, Step 2, …. The name of the step's tab.

comark-magic-move takes no options. magicMove: false in comarkUi() turns it off.

The plugin adds each problem to meta.issues, as Validation describes:

magic-move 1 has one code block, so there's nothing to move between; add a code block for each step, or use a plain code block.
txt

It reports:

  • A block with fewer than two code blocks.
  • Content that isn't a code block. The block drops it.
  • A prop it doesn't know.

Each one has its own rule in Doctor.

comark-ui installs and registers comark-magic-move. To use it alone, install it and pass the renderer's components:

pnpm add comark @comark/html comark-magic-move
import { createHtmlRenderer } from "@comark/html";
import magicMove, { components } from "comark-magic-move/html";

const render = createHtmlRenderer({ plugins: [magicMove()], components });
ts

For Vue, React, or Svelte, import from comark-magic-move/vue, comark-magic-move/react, or comark-magic-move/svelte. On a server, import comark-magic-move/style.css with your styles. Renderers covers the rest.

The tabs follow the shared tokens in Theming, and two variables set the header and the space between steps. The code keeps its highlighter's colors. The rules sit in a comark CSS layer, so any plain rule of yours wins.

Token Used for
--comark-background The open tab
--comark-foreground The open and hovered tabs' text
--comark-muted The other tabs' text, and the captions without the script
--comark-border The header's border
--comark-accent The header's surface
--comark-ring The focus ring
--comark-radius The corners

Set these on .cmv or any ancestor, such as :root:

Variable Default Used for
--cmv-surface --comark-accent at 50% The header behind the tabs
--cmv-gap 1rem The space between steps when they read in turn, without the script
Selector Is
.cmv The block, with comark-code's data-code-header, so each step's buttons sit in the tabs' strip
.cmv-list The tabs, shown once the script runs
.cmv-tab A tab, with aria-selected="true" when its step is open
.cmv-stage The steps
.cmv-step Each step, with data-inactive on every step but the open one
.cmv-caption A step's label above its code, shown without the script
.cmv-t A word or mark the script cut from the code, on the first move
[data-magic-move-ready] On <html> once the script runs. Without it, every step shows

A header that matches the code block:

:root {
  --cmv-surface: var(--comark-background);
}
css

To try it, paste it into Extra CSS in the playground's Theme pane.