Animated Code Blocks Using Shiki Magic Move

Published Apr 14, 2024

Table of Contents

What Is Shiki Magic Move?

You can click on the example on the page to activate the code block animation. You can find the source code on GitHub.

Shiki is a powerful, modern JavaScript syntax highlighter. Shiki Magic Move is a low-level framework agnostic library for animating code blocks which uses Shiki for syntax highlighting β€” it has framework wrappers for Vue and React, but in this post I’m going to show you how to make a Svelte renderer.

You can read The Magic In Shiki Magic Move if you want to understand how Shiki Magic Move works, but the gist is that it uses text diffing, and the FLIP animation technique to animate the changes.

Huge thanks to @antfu for not only creating Shiki Magic Move, but also completely rewriting Shiki for the modern JavaScript age to use ESM, and run anywhere where JavaScript runs.

Project Setup

Install the shiki and shiki-magic-move dependencies:

terminal
npm i shiki shiki-magic-move

Inside a SvelteKit project create these files:

files
src/
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ index.ts
β”‚   β”œβ”€β”€ ShikiMagicMove.svelte
β”‚   └── ShikiMagicMoveRenderer.svelte
└── routes/
    β”œβ”€β”€ +layout.svelte
    └── app.css

You can find the types I use throughout the examples on GitHub.

You could put everything inside a single file, but I’m going to mirror the setup for other framework renderers.

src/lib/index.ts
import ShikiMagicMove from './ShikiMagicMove.svelte'
import ShikiMagicMoveRenderer from './ShikiMagicMoveRenderer.svelte'

export { ShikiMagicMove, ShikiMagicMoveRenderer }

You should copy over these Shiki Magic Move styles inside app.css, and include it inside your root layout.

src/routes/+layout.svelte
<script lang="ts">
	import './app.css'

  let { children } = $props()
</script>

<svelte:head>
  <title>Shiki</title>
</svelte:head>

{@render children()}

Creating The Shiki Highlighter

Inside +page.svelte create the Shiki highlighter and pass it to the <ShikiMagicMove> component.

src/routes/+pages.svelte
<script lang="ts">
	import { getHighlighter } from 'shiki'
	import ShikiMagicMove from '$lib/ShikiMagicMove.svelte'

	let code = $state('let bool;')
	let toggle = $state(false)

	const highlighter = getHighlighter({
		themes: ['poimandres'],
		langs: ['javascript', 'typescript', 'svelte'],
	})

	function toggleAnimation() {
		toggle = !toggle
		toggle ? (code = 'let bool = true;') : (code = 'let bool;')
	}
</script>

<svelte:window on:click={toggleAnimation} />

{#await highlighter then highlighter}
	<ShikiMagicMove
		lang="ts"
		theme="poimandres"
		options={{ duration: 600, stagger: 3 }}
		onStart={() => console.log('start')}
		onEnd={() => console.log('end')}
		{highlighter}
		{code}
	/>
{/await}

Shiki Magic Move Component

Inside the <ShikiMagicMove> component we have to create the machine to tokenize the code, derive the result each time the reactive code value updates using Svelte’s $derived rune, and pass the props to the <ShikiMagicMoveRenderer> component.

src/lib/ShikiMagicMove.svelte
<script lang="ts">
	import { codeToKeyedTokens, createMagicMoveMachine } from 'shiki-magic-move/core'
	import ShikiMagicMoveRenderer from './ShikiMagicMoveRenderer.svelte'
	import type { ShikiMagicMoveProps } from './types'

	const { ...props }: ShikiMagicMoveProps = $props()

	const machine = createMagicMoveMachine(
		(code) =>
			codeToKeyedTokens(props.highlighter, code, {
				lang: props.lang,
				theme: props.theme,
			}),
		props.options
	)
	const result = $derived(machine.commit(props.code))
</script>

<ShikiMagicMoveRenderer
	animate={true}
	tokens={result.current}
	previous={result.previous}
	options={props.options}
	onStart={props.onStart}
	onEnd={props.onEnd}
/>

Shiki Magic Move Renderer Component

Inside the <ShikiMagicMoveRenderer> component we have to render the <pre> tag, create the MagicMoveRenderer, and invoke the render() method using the tokens to start the animation, which is going to rerun the $effect each time tokens update.

src/lib/ShikiMagicMoveRenderer.svelte
<script lang="ts">
	import { MagicMoveRenderer } from 'shiki-magic-move/renderer'
	import type { ShikiMagicMoveRendererProps } from './types'

	const { ...props }: ShikiMagicMoveRendererProps = $props()

	let container: HTMLPreElement
	let renderer: MagicMoveRenderer
	let isMounted = $state(false)

	$effect(() => {
		if (!container) return
		container.innerHTML = ''
		isMounted = true
    // create the magic move renderer
		renderer = new MagicMoveRenderer(container)
	})

	$effect(() => {
		async function render() {
			if (!renderer) return
      // merge renderer options with our options
			Object.assign(renderer.options, props.options)
			if (props.animate) {
        // replace previous animation
				if (props.previous) renderer.replace(props.previous)
        // optional start callback
				props.onStart?.()
        // run the animation and return a promise
				await renderer.render(props.tokens)
        // optional end callback
				props.onEnd?.()
			} else {
        // update code without animation
				renderer.replace(props.tokens)
			}
		}
		render()
	})
</script>

<pre bind:this={container} class="shiki-magic-move-container">
  <!-- render initial tokens for SSR -->
  {#if !isMounted}
		{#each props.tokens.tokens as token}
			{#if token.content === '\n'}
				<br />
			{/if}
      <span class="shiki-magic-move-item">
        {token.content}
      </span>
		{/each}
	{/if}
</pre>

The server-side rendering code is unimportant because it only serves as a placeholder until the component is ready, and I only included it for posterity.

You can watch the video on YouTube where I do everything from scratch, and spend more time explaining things.

That’s it! πŸ˜„

Support

If you want to support the content you're reading or watching on YouTube consider becoming a patreon starting low as 1$ per month.

Become a patreon
Subscribe For Updates
Found a mistake?

Every post is a Markdown file so contributing is simple as following the link below and pressing the pencil icon inside GitHub to edit it.

Edit on GitHub