|
| 1 | +--- |
| 2 | +outline: deep |
| 3 | +--- |
| 4 | + |
| 5 | +# @shikijs/magic-move |
| 6 | + |
| 7 | +<Badges name="@shikijs/magic-move" /> |
| 8 | + |
| 9 | +Smoothly animated code blocks with Shiki. Useful for code-step animations in slide decks (e.g. [Slidev](https://sli.dev/guide/syntax#shiki-magic-move)) and tutorials. |
| 10 | + |
| 11 | +`@shikijs/magic-move` is a low-level library: at its core is a framework-agnostic [diff/animation machine](https://github.com/shikijs/shiki/blob/main/packages/magic-move/src/core.ts) and [renderer](https://github.com/shikijs/shiki/blob/main/packages/magic-move/src/renderer.ts), with thin wrappers for Vue, React, Solid, Svelte, and a Web Component. |
| 12 | + |
| 13 | +Each framework wrapper provides three components: |
| 14 | + |
| 15 | +- **`ShikiMagicMove`** — the main component; wraps a code block and animates whenever `code` changes |
| 16 | +- **`ShikiMagicMovePrecompiled`** — animations for compiled tokens, without the Shiki dependency at runtime |
| 17 | +- **`ShikiMagicMoveRenderer`** — the low-level renderer component |
| 18 | + |
| 19 | +`ShikiMagicMove` needs a Shiki highlighter instance plus the bundled stylesheet (`@shikijs/magic-move/style.css`). Whenever `code` changes, the component animates the diff. |
| 20 | + |
| 21 | +## Install |
| 22 | + |
| 23 | +::: code-group |
| 24 | + |
| 25 | +```sh [npm] |
| 26 | +npm i -D @shikijs/magic-move shiki |
| 27 | +``` |
| 28 | + |
| 29 | +```sh [yarn] |
| 30 | +yarn add -D @shikijs/magic-move shiki |
| 31 | +``` |
| 32 | + |
| 33 | +```sh [pnpm] |
| 34 | +pnpm add -D @shikijs/magic-move shiki |
| 35 | +``` |
| 36 | + |
| 37 | +```sh [bun] |
| 38 | +bun add -D @shikijs/magic-move shiki |
| 39 | +``` |
| 40 | + |
| 41 | +```sh [deno] |
| 42 | +deno add npm:@shikijs/magic-move npm:shiki |
| 43 | +``` |
| 44 | + |
| 45 | +::: |
| 46 | + |
| 47 | +## Usage |
| 48 | + |
| 49 | +### Vue |
| 50 | + |
| 51 | +Import `@shikijs/magic-move/vue` and pass the highlighter instance to the `ShikiMagicMove` component. |
| 52 | + |
| 53 | +```vue |
| 54 | +<script setup> |
| 55 | +import { ShikiMagicMove } from '@shikijs/magic-move/vue' |
| 56 | +import { createHighlighter } from 'shiki' |
| 57 | +import { ref } from 'vue' |
| 58 | +
|
| 59 | +import '@shikijs/magic-move/style.css' |
| 60 | +
|
| 61 | +const highlighter = await createHighlighter({ |
| 62 | + themes: ['nord'], |
| 63 | + langs: ['javascript', 'typescript'], |
| 64 | +}) |
| 65 | +
|
| 66 | +const code = ref(`const hello = 'world'`) |
| 67 | +
|
| 68 | +function animate() { |
| 69 | + code.value = `let hi = 'hello'` |
| 70 | +} |
| 71 | +</script> |
| 72 | +
|
| 73 | +<template> |
| 74 | + <ShikiMagicMove |
| 75 | + lang="ts" |
| 76 | + theme="nord" |
| 77 | + :highlighter="highlighter" |
| 78 | + :code="code" |
| 79 | + :options="{ duration: 800, stagger: 0.3, lineNumbers: true }" |
| 80 | + /> |
| 81 | + <button @click="animate"> |
| 82 | + Animate |
| 83 | + </button> |
| 84 | +</template> |
| 85 | +``` |
| 86 | + |
| 87 | +### React |
| 88 | + |
| 89 | +Import `@shikijs/magic-move/react` and pass the highlighter instance to the `ShikiMagicMove` component. |
| 90 | + |
| 91 | +```tsx |
| 92 | +import type { HighlighterCore } from 'shiki' |
| 93 | +import { ShikiMagicMove } from '@shikijs/magic-move/react' |
| 94 | +import { useEffect, useState } from 'react' |
| 95 | +import { createHighlighter } from 'shiki' |
| 96 | + |
| 97 | +import '@shikijs/magic-move/style.css' |
| 98 | + |
| 99 | +function App() { |
| 100 | + const [code, setCode] = useState(`const hello = 'world'`) |
| 101 | + const [highlighter, setHighlighter] = useState<HighlighterCore>() |
| 102 | + |
| 103 | + useEffect(() => { |
| 104 | + async function initializeHighlighter() { |
| 105 | + const h = await createHighlighter({ |
| 106 | + themes: ['nord'], |
| 107 | + langs: ['javascript', 'typescript'], |
| 108 | + }) |
| 109 | + setHighlighter(h) |
| 110 | + } |
| 111 | + initializeHighlighter() |
| 112 | + }, []) |
| 113 | + |
| 114 | + function animate() { |
| 115 | + setCode(`let hi = 'hello'`) |
| 116 | + } |
| 117 | + |
| 118 | + return ( |
| 119 | + <div> |
| 120 | + {highlighter && ( |
| 121 | + <> |
| 122 | + <ShikiMagicMove |
| 123 | + lang="ts" |
| 124 | + theme="nord" |
| 125 | + highlighter={highlighter} |
| 126 | + code={code} |
| 127 | + options={{ duration: 800, stagger: 0.3, lineNumbers: true }} |
| 128 | + /> |
| 129 | + <button onClick={animate}>Animate</button> |
| 130 | + </> |
| 131 | + )} |
| 132 | + </div> |
| 133 | + ) |
| 134 | +} |
| 135 | +``` |
| 136 | + |
| 137 | +### Solid |
| 138 | + |
| 139 | +Import `@shikijs/magic-move/solid` and pass the highlighter instance to the `ShikiMagicMove` component. |
| 140 | + |
| 141 | +```tsx |
| 142 | +import { ShikiMagicMove } from '@shikijs/magic-move/solid' |
| 143 | +import { bundledLanguages, bundledThemes, createHighlighter } from 'shiki' |
| 144 | +import { createResource, createSignal, Show } from 'solid-js' |
| 145 | + |
| 146 | +import '@shikijs/magic-move/style.css' |
| 147 | + |
| 148 | +function App() { |
| 149 | + const [code, setCode] = createSignal(`const hello = 'world'`) |
| 150 | + |
| 151 | + const [highlighter] = createResource(async () => { |
| 152 | + return await createHighlighter({ |
| 153 | + themes: Object.keys(bundledThemes), |
| 154 | + langs: Object.keys(bundledLanguages), |
| 155 | + }) |
| 156 | + }) |
| 157 | + |
| 158 | + function animate() { |
| 159 | + setCode(`let hi = 'hello'`) |
| 160 | + } |
| 161 | + |
| 162 | + return ( |
| 163 | + <div> |
| 164 | + <Show when={highlighter()}> |
| 165 | + {h => ( |
| 166 | + <> |
| 167 | + <ShikiMagicMove |
| 168 | + lang="ts" |
| 169 | + theme="nord" |
| 170 | + highlighter={h()} |
| 171 | + code={code()} |
| 172 | + options={{ duration: 800, stagger: 0.3, lineNumbers: true }} |
| 173 | + /> |
| 174 | + <button onClick={animate}>Animate</button> |
| 175 | + </> |
| 176 | + )} |
| 177 | + </Show> |
| 178 | + </div> |
| 179 | + ) |
| 180 | +} |
| 181 | +``` |
| 182 | + |
| 183 | +### Svelte |
| 184 | + |
| 185 | +Import `@shikijs/magic-move/svelte` and pass the highlighter instance to the `ShikiMagicMove` component. |
| 186 | + |
| 187 | +```svelte |
| 188 | +<script lang="ts"> |
| 189 | + import { ShikiMagicMove } from '@shikijs/magic-move/svelte' |
| 190 | + import { createHighlighter } from 'shiki' |
| 191 | +
|
| 192 | + import '@shikijs/magic-move/style.css' |
| 193 | +
|
| 194 | + const highlighter = createHighlighter({ |
| 195 | + themes: ['nord'], |
| 196 | + langs: ['javascript', 'typescript'], |
| 197 | + }) |
| 198 | +
|
| 199 | + let code = $state(`const hello = 'world'`) |
| 200 | +
|
| 201 | + function animate() { |
| 202 | + code = `let hi = 'hello'` |
| 203 | + } |
| 204 | +</script> |
| 205 | +
|
| 206 | +{#await highlighter then highlighter} |
| 207 | + <ShikiMagicMove |
| 208 | + lang="ts" |
| 209 | + theme="nord" |
| 210 | + {highlighter} |
| 211 | + {code} |
| 212 | + options={{ duration: 800, stagger: 0.3, lineNumbers: true }} |
| 213 | + /> |
| 214 | + <button onclick={animate}>Animate</button> |
| 215 | +{/await} |
| 216 | +``` |
| 217 | + |
| 218 | +## `ShikiMagicMovePrecompiled` |
| 219 | + |
| 220 | +`ShikiMagicMovePrecompiled` is a lighter variant of `ShikiMagicMove` that doesn't need Shiki at runtime — useful when you want to ship pre-tokenized step data (e.g. produced at build time) and animate between steps in the browser. |
| 221 | + |
| 222 | +```vue |
| 223 | +<script setup> |
| 224 | +import { ShikiMagicMovePrecompiled } from '@shikijs/magic-move/vue' |
| 225 | +import { ref } from 'vue' |
| 226 | +
|
| 227 | +const step = ref(1) |
| 228 | +const compiledSteps = [/* Compiled token steps */] |
| 229 | +</script> |
| 230 | +
|
| 231 | +<template> |
| 232 | + <ShikiMagicMovePrecompiled |
| 233 | + :steps="compiledSteps" |
| 234 | + :step="step" |
| 235 | + /> |
| 236 | + <button @click="step++"> |
| 237 | + Next |
| 238 | + </button> |
| 239 | +</template> |
| 240 | +``` |
| 241 | + |
| 242 | +To produce the compiled tokens, run this somewhere with access to Shiki (build script, server, etc.) and serialize the result: |
| 243 | + |
| 244 | +```ts |
| 245 | +import { codeToKeyedTokens, createMagicMoveMachine } from '@shikijs/magic-move/core' |
| 246 | +import { createHighlighter } from 'shiki' |
| 247 | + |
| 248 | +const shiki = await createHighlighter({ |
| 249 | + themes: ['nord'], |
| 250 | + langs: ['javascript', 'typescript'], |
| 251 | +}) |
| 252 | + |
| 253 | +const codeSteps = [ |
| 254 | + `const hello = 'world'`, |
| 255 | + `let hi = 'hello'`, |
| 256 | +] |
| 257 | + |
| 258 | +const machine = createMagicMoveMachine( |
| 259 | + code => codeToKeyedTokens(shiki, code, { |
| 260 | + lang: 'ts', |
| 261 | + theme: 'nord', |
| 262 | + }), |
| 263 | + { |
| 264 | + // options |
| 265 | + }, |
| 266 | +) |
| 267 | + |
| 268 | +const compiledSteps = codeSteps.map(code => machine.commit(code).current) |
| 269 | + |
| 270 | +// Pass `compiledSteps` to the precompiled component. |
| 271 | +// Since these are plain serialisable objects, you can stringify them at build time |
| 272 | +// and rehydrate them in the browser without needing Shiki. |
| 273 | +``` |
| 274 | + |
| 275 | +## How it works |
| 276 | + |
| 277 | +For a deep dive into the algorithm behind the animations, see Anthony's article [**The Magic In Shiki Magic Move**](https://antfu.me/posts/shiki-magic-move). |
0 commit comments