Decorations

Decorations highlight or obscure select lines or sections of code. Just like your IDE can highlight the current line, underline type errors or place icons the gutter, so can you!

function add(a: number, b: number): number {
  return a + b;
}

add(23, "42"); // ← This is wrong

function add(a: number, b: number): number {
  return a + b; // ← This is an important line
}

add(23, "42");

function add(a: number, b: number): number {
  return a + b;
}

➡️add(23, "42"); // ← Pay attention to this line

function add(a: number, b: number): number {
  return a + b;
}

add(23, 42); // ← This works now!

function add(a: number, b: number): number {
  return a + b;
}

add(23, 42); // ← This returns   
function add(a: number, b: number): number {
  return a + b;
}

👌add(23, 42); // ← This returns 65

Like with code, you add decorations declaratively to each code frame and let the library worry about computing the animation. The following example shows how a basic text decoration works:

let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "Hello" (code points 4 to 11)
      { kind: "TEXT", data: {}, from: 4, to: 11 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "World" (code points 15 to 21)
      { kind: "TEXT", data: {}, from: 15, to: 22 },
    ],
  },
];

import {
  fromStringsToScene,
  toAnimationHTML,
} from "@codemovie/code-movie/dist/index.js";
import json from "@codemovie/code-movie/languages/json";

let scene = fromStringsToScene(keyframes, {
  tabSize: 2,
  language: json(),
});

import "@codemovie/code-movie-runtime";
document.body.innerHTML = `<code-movie-runtime controls keyframes="0 1">${toAnimationHTML(scene)}</code-movie-runtime>`;
123456789101112131415161718192021222324252627282930
let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "Hello" (code points 4 to 11)
      { kind: "TEXT", data: {}, from: 4, to: 11 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "World" (code points 15 to 21)
      { kind: "TEXT", data: {}, from: 15, to: 22 },
    ],
  },
];

import {
  fromStringsToScene,
  toAnimationHTML,
} from "@codemovie/code-movie/dist/index.js";
import json from "@codemovie/code-movie/languages/json";

let scene = fromStringsToScene(keyframes, {
  tabSize: 2,
  language: json(),
});

import "@codemovie/code-movie-runtime";
document.body.innerHTML = `<code-movie-runtime controls keyframes="0 1">${toAnimationHTML(scene)}</code-movie-runtime>`;

Result:

[
  "Hello",
  "World"
]
[
  "Hello",
  "World"
]

Decorations are a fairly powerful feature and not without some complexity. But since they can really elevate your animations to the next level, learning how they work is definitely worth it.

Decoration kinds

Gutter decorations

A gutter decoration renders a single unicode character somewhere near the line number colum. You can use the full range of unicode in general and emoji in particular to express things like errors ❌, approval ✅, personal opinion 🤮, additions ➕, deletions ➖, and much more.

[
➡️  "Hello",
  "World"
]
[
  "Hello",
➡️  "World"
]

The above example illustrates how the default gutter decoration style is pretty much just a static character. The example can be recreated like this:

let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Points to the line containing "Hello"
      { kind: "GUTTER", data: {}, text: "➡️", line: 2 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Points to the line containing "World"
      { kind: "GUTTER", data: {}, text: "➡️", line: 3 },
    ],
  },
];

import { fromStringsToScene, toAnimationHTML } from "@codemovie/code-movie";
import json from "@codemovie/code-movie/languages/json";

let scene = fromStringsToScene(keyframes, {
  tabSize: 2,
  language: json(),
});

import "@codemovie/code-movie-runtime";
document.body.innerHTML = `<code-movie-runtime controls keyframes="0 1">${toAnimationHTML(scene)}</code-movie-runtime>`;
123456789101112131415161718192021222324252627
let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Points to the line containing "Hello"
      { kind: "GUTTER", data: {}, text: "➡️", line: 2 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Points to the line containing "World"
      { kind: "GUTTER", data: {}, text: "➡️", line: 3 },
    ],
  },
];

import { fromStringsToScene, toAnimationHTML } from "@codemovie/code-movie";
import json from "@codemovie/code-movie/languages/json";

let scene = fromStringsToScene(keyframes, {
  tabSize: 2,
  language: json(),
});

import "@codemovie/code-movie-runtime";
document.body.innerHTML = `<code-movie-runtime controls keyframes="0 1">${toAnimationHTML(scene)}</code-movie-runtime>`;

Some tips and tricks for gutter decorations:

Line decorations

A line decoration renders up to two boxes (one in the text foreground, one in the background) across one or more entire lines. You can use the background to highlight lines and the foreground to obscure content.

[
  "Hello",
  "World"
]
[
  "Hello",
  "World"
]
[
  "Hello",
  "World"
]

The above example illustrates how the default line decoration style is a subtle yellow highlight. The example can be recreated like this:

let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Covers the line containing "Hello"
      { kind: "LINE", data: {}, fromLine: 2, toLine: 2 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Covers the line containing "World"
      { kind: "LINE", data: {}, fromLine: 3, toLine: 3 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Covers both both lines
      { kind: "LINE", data: {}, fromLine: 2, toLine: 3 },
    ],
  },
];

import { fromStringsToScene, toAnimationHTML } from "@codemovie/code-movie";
import json from "@codemovie/code-movie/languages/json";

let scene = fromStringsToScene(keyframes, {
  tabSize: 2,
  language: json(),
});

import "@codemovie/code-movie-runtime";
document.body.innerHTML = `<code-movie-runtime controls keyframes="0 1 2">${toAnimationHTML(scene)}</code-movie-runtime>`;
12345678910111213141516171819202122232425262728293031323334
let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Covers the line containing "Hello"
      { kind: "LINE", data: {}, fromLine: 2, toLine: 2 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Covers the line containing "World"
      { kind: "LINE", data: {}, fromLine: 3, toLine: 3 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Covers both both lines
      { kind: "LINE", data: {}, fromLine: 2, toLine: 3 },
    ],
  },
];

import { fromStringsToScene, toAnimationHTML } from "@codemovie/code-movie";
import json from "@codemovie/code-movie/languages/json";

let scene = fromStringsToScene(keyframes, {
  tabSize: 2,
  language: json(),
});

import "@codemovie/code-movie-runtime";
document.body.innerHTML = `<code-movie-runtime controls keyframes="0 1 2">${toAnimationHTML(scene)}</code-movie-runtime>`;

Two line decorations count as equal when their data fields contain equal values.

The default line decoration highlights a line with a light yellow background. To add variants to this, write CSS rules targeting selectors that match any of the tag names and/or attributes set in the decoration's data fields:

/* Decoration for dead code */
.deadcode {
  /* No line decoration background color */
  --cm-decoration-line-background-background: none;
  /* Semi-transparent line decoration foreground color */
  --cm-decoration-line-foreground-background: rgba(255, 255, 255, 0.333);
  /* Backdrop filter for a slight blur effect */
  --cm-decoration-line-foreground-backdrop-filter: blur(0.1em);
  /* Set an offset in order to not blur the line number */
  --cm-decoration-line-foreground-offset-left: calc(
    var(--line-numbers-width) +
    var(--cm-line-numbers-margin-left) +
    var(--cm-content-margin-left)
  );
}
123456789101112131415
/* Decoration for dead code */
.deadcode {
  /* No line decoration background color */
  --cm-decoration-line-background-background: none;
  /* Semi-transparent line decoration foreground color */
  --cm-decoration-line-foreground-background: rgba(255, 255, 255, 0.333);
  /* Backdrop filter for a slight blur effect */
  --cm-decoration-line-foreground-backdrop-filter: blur(0.1em);
  /* Set an offset in order to not blur the line number */
  --cm-decoration-line-foreground-offset-left: calc(
    var(--line-numbers-width) +
    var(--cm-line-numbers-margin-left) +
    var(--cm-content-margin-left)
  );
}

To use this class just make sure that the decoration's data field has an entry for class or className

let keyframes = [
  {
    code: `\n// Everthing after line 3 is dead code\nfunction example(x) {\n  return null;\n  return x;\n}`,
    decorations: [
      // Covers the line containing "Hello"
      { kind: "LINE", data: { className: "deadcode" }, fromLine: 4, toLine: 4 },
    ],
  },
];
123456789
let keyframes = [
  {
    code: `\n// Everthing after line 3 is dead code\nfunction example(x) {\n  return null;\n  return x;\n}`,
    decorations: [
      // Covers the line containing "Hello"
      { kind: "LINE", data: { className: "deadcode" }, fromLine: 4, toLine: 4 },
    ],
  },
];

Result:

// Everthing after line 3 is dead code
function example(x) {
  return null;
  return x;
}

The same approach works with text decorations. Check out the CSS variables that are available to tweak line decorations! The classes error and ok are available as built-in extra styles for both line and text decorations.

Some more tips and tricks for line decorations:

Text decorations

Text decorations decorate specific sections of code with backgrounds and/or underlines:

function add(a: number, b: number): number {
  return a + b;
}

add(23, 42); add(23n, 42n); // ← only accepts numbers

function add(a: number, b: number): number {
  return a + b;
}

add(23, 42); add(23n, 42n); // ← only accepts numbers

function add<T>(a: T, b: T): T {
  return a + b; // ← can't add ANY type
}

add(23, 42); add(23n, 42n); // ← this works now

function add<T>(a: T, b: T): T { // ← TOO generic
  return a + b; // ← can't add ANY type
}

add(23, 42); add(23n, 42n);

function add<T extends number | bigint>(a: T, b: T): T {
  return a + b;
}

add(23, 42); add(23n, 42n);

Every text decoration renders up to four elements per decorated section. Going from the background to the foreground this can include:

  1. A background backdrop layer
  2. A background underline layer
  3. A foreground backdrop layer
  4. A foreground underline layer

The highlighted code is sandwiched between the background and foreground layers.

The above example illustrates how text decorations are made up of backgrounds and underlines. The example borders on non-trivial, but can be recreated like this:

Show keyframes for the above example
let keyframes = [
  {
    code: "function add(a: number, b: number): number {\n  return a + b;\n}\n\nadd(23, 42);\nadd(23n, 42n); // ← only accepts numbers",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          class: "error",
          tagName: "mark",
        },
        from: 77,
        to: 91,
      },
    ],
  },
  {
    code: "function add(a: number, b: number): number {\n  return a + b;\n}\n\nadd(23, 42);\nadd(23n, 42n); // ← only accepts numbers",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 16,
        to: 22,
      },
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 27,
        to: 33,
      },
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 36,
        to: 42,
      },
      {
        kind: "TEXT",
        data: {
          class: "error",
          tagName: "mark",
        },
        from: 77,
        to: 91,
      },
    ],
  },
  {
    code: "function add<T>(a: T, b: T): T {\n  return a + b; // ← can't add ANY type\n}\n\nadd(23, 42);\nadd(23n, 42n); // ← this works now",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 19,
        to: 20,
      },
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 25,
        to: 26,
      },
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 29,
        to: 30,
      },
      {
        kind: "TEXT",
        data: {
          class: "error",
          tagName: "mark",
        },
        from: 35,
        to: 48,
      },
    ],
  },
  {
    code: "function add<T>(a: T, b: T): T { // ← TOO generic\n  return a + b; // ← can't add ANY type\n}\n\nadd(23, 42);\nadd(23n, 42n);",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 12,
        to: 15,
      },
      {
        kind: "TEXT",
        data: {
          class: "error",
          tagName: "mark",
        },
        from: 52,
        to: 65,
      },
    ],
  },
  {
    code: "function add<T extends number | bigint>(a: T, b: T): T {\n  return a + b;\n}\n\nadd(23, 42);\nadd(23n, 42n);",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          class: "ok",
          tagName: "mark",
        },
        from: 15,
        to: 38,
      },
      {
        kind: "GUTTER",
        data: {
          class: "gutter",
          tagName: "mark",
        },
        line: 5,
        text: "✅",
      },
      {
        kind: "GUTTER",
        data: {
          class: "gutter",
          tagName: "mark",
        },
        line: 6,
        text: "✅",
      },
    ],
  },
];
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
let keyframes = [
  {
    code: "function add(a: number, b: number): number {\n  return a + b;\n}\n\nadd(23, 42);\nadd(23n, 42n); // ← only accepts numbers",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          class: "error",
          tagName: "mark",
        },
        from: 77,
        to: 91,
      },
    ],
  },
  {
    code: "function add(a: number, b: number): number {\n  return a + b;\n}\n\nadd(23, 42);\nadd(23n, 42n); // ← only accepts numbers",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 16,
        to: 22,
      },
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 27,
        to: 33,
      },
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 36,
        to: 42,
      },
      {
        kind: "TEXT",
        data: {
          class: "error",
          tagName: "mark",
        },
        from: 77,
        to: 91,
      },
    ],
  },
  {
    code: "function add<T>(a: T, b: T): T {\n  return a + b; // ← can't add ANY type\n}\n\nadd(23, 42);\nadd(23n, 42n); // ← this works now",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 19,
        to: 20,
      },
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 25,
        to: 26,
      },
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 29,
        to: 30,
      },
      {
        kind: "TEXT",
        data: {
          class: "error",
          tagName: "mark",
        },
        from: 35,
        to: 48,
      },
    ],
  },
  {
    code: "function add<T>(a: T, b: T): T { // ← TOO generic\n  return a + b; // ← can't add ANY type\n}\n\nadd(23, 42);\nadd(23n, 42n);",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          tagName: "mark",
        },
        from: 12,
        to: 15,
      },
      {
        kind: "TEXT",
        data: {
          class: "error",
          tagName: "mark",
        },
        from: 52,
        to: 65,
      },
    ],
  },
  {
    code: "function add<T extends number | bigint>(a: T, b: T): T {\n  return a + b;\n}\n\nadd(23, 42);\nadd(23n, 42n);",
    ranges: [],
    decorations: [
      {
        kind: "TEXT",
        data: {
          class: "ok",
          tagName: "mark",
        },
        from: 15,
        to: 38,
      },
      {
        kind: "GUTTER",
        data: {
          class: "gutter",
          tagName: "mark",
        },
        line: 5,
        text: "✅",
      },
      {
        kind: "GUTTER",
        data: {
          class: "gutter",
          tagName: "mark",
        },
        line: 6,
        text: "✅",
      },
    ],
  },
];

Note that the start and end indices from and to are the offsets of unicode code points, which may not correspond 1:1 with the character count. Two text decorations count as equal when their data fields contain equal values.

The default text decoration highlights its range with a bright yellow background like a <mark> element. Like with line decorations, you can write CSS rules targeting selectors that match any of the tag names and/or attributes set in the decoration's data fields. The classes error and ok are available as built-in extra styles for both line and text decorations.

Some more tips and tricks for line decorations:

Decoration customization

Decorations are rendered as regular HTML elements. To customize the element specifics you can set properties on the decorator's data field.

This allows you to set up decorations as targets for your custom CSS and you can even attach scripted behavior by using a custom element as data.tagName:

let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "Hello" (code points 4 to 11)
      {
        kind: "TEXT",
        data: {
          tagName: "mark", // use <mark> instead of the default <span>,
          class: "customClass" // add "customClass" to the class attribute
          "data-something": "42" // set attribute data-something="42"
          "_nope": "42" // will not show up as an attribute
        },
        from: 4,
        to: 11
      },
    ],
  },
];
12345678910111213141516171819
let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "Hello" (code points 4 to 11)
      {
        kind: "TEXT",
        data: {
          tagName: "mark", // use <mark> instead of the default <span>,
          class: "customClass" // add "customClass" to the class attribute
          "data-something": "42" // set attribute data-something="42"
          "_nope": "42" // will not show up as an attribute
        },
        from: 4,
        to: 11
      },
    ],
  },
];

Every kind of decoration (gutter, line, text) has a different set of CSS variables available for styling purposes. By setting custom classes (or other attributes) on data.attributes you can create your own custom set of differently-styled decorations. Personally, I like to use text decorations not just to highlight certain sections, but also to block out certain sections of code:

/* Keep the audience guessing */
.decoration-text-blackout {
  /* Overlays the code being decorated */
  --cm-decoration-text-foreground-background: black;
  /* feTurbulence + feDisplacementMap from an inline SVG plus regular blur */
  --cm-decoration-text-foreground-filter: url(#decoBackgroundFilter) blur(0.2px);
  /* Undo the default yellow background (we use only the foreground) */
  --cm-decoration-text-background-background: transparent;
}

/* Partially obscure unreachable code behind something */
.decoration-text-unreachable {
  /* Overlays the code being decorated (assuming the background is also #fff) */
  --cm-decoration-text-foreground-background: #fff;
  /* Reduce opacity */
  --cm-decoration-text-foreground-filter: opacity(50%);
  /* Undo the default yellow background */
  --cm-decoration-text-background-background: transparent;
}
12345678910111213141516171819
/* Keep the audience guessing */
.decoration-text-blackout {
  /* Overlays the code being decorated */
  --cm-decoration-text-foreground-background: black;
  /* feTurbulence + feDisplacementMap from an inline SVG plus regular blur */
  --cm-decoration-text-foreground-filter: url(#decoBackgroundFilter) blur(0.2px);
  /* Undo the default yellow background (we use only the foreground) */
  --cm-decoration-text-background-background: transparent;
}

/* Partially obscure unreachable code behind something */
.decoration-text-unreachable {
  /* Overlays the code being decorated (assuming the background is also #fff) */
  --cm-decoration-text-foreground-background: #fff;
  /* Reduce opacity */
  --cm-decoration-text-foreground-filter: opacity(50%);
  /* Undo the default yellow background */
  --cm-decoration-text-background-background: transparent;
}

Note that different kinds of decorations do not share any CSS variable names. You can get by with just one class named .something that hosts the settings for both text and line decorations:

.something {
  /* Text */
  --cm-decoration-text-foreground-underline-offset-y: -0.5;
  --cm-decoration-text-foreground-underline-scale: 0.15;
  --cm-decoration-text-foreground-underline-width: 10;
  --cm-decoration-text-foreground-underline-squiggly: 1;
  --cm-decoration-text-foreground-underline-color: #c00;
  --cm-decoration-text-background-background: none;
  /* Line */
  --cm-decoration-line-background-background: #ffeeee;
}
1234567891011
.something {
  /* Text */
  --cm-decoration-text-foreground-underline-offset-y: -0.5;
  --cm-decoration-text-foreground-underline-scale: 0.15;
  --cm-decoration-text-foreground-underline-width: 10;
  --cm-decoration-text-foreground-underline-squiggly: 1;
  --cm-decoration-text-foreground-underline-color: #c00;
  --cm-decoration-text-background-background: none;
  /* Line */
  --cm-decoration-line-background-background: #ffeeee;
}

Decoration defaults

Apart from the default styles for line and text decorations, the default theme comes with two additional presets for line and text decorations each: error and ok:

Default text decoration
"ok" text decoration
"error" text decoration
Default line decoration
"ok" line decoration
"error" line decoration

You can use the presets by setting data.class on line or text decorations to contain the class names error or ok.

Decorations and the diffing algorithm

From the perspective of the diffing algorithm, every decoration is represented as a hash constructed from its kind and data fields. Two decorations of the same kind and with equivalent data fields are therefore treated as interchangeable. Consider how the text decoration in the following example moves between frames:

[
  "Hello",
  "World"
]
[
  "Hello",
  "World"
]

This is happens because the decoration object has the same kind and equivalent data in both frames:

let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "Hello" (code points 4 to 11)
      { kind: "TEXT", data: {}, from: 4, to: 11 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "World" (code points 15 to 21)
      { kind: "TEXT", data: {}, from: 15, to: 22 },
    ],
  },
];

// ... do something with the keyframes
123456789101112131415161718
let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "Hello" (code points 4 to 11)
      { kind: "TEXT", data: {}, from: 4, to: 11 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "World" (code points 15 to 21)
      { kind: "TEXT", data: {}, from: 15, to: 22 },
    ],
  },
];

// ... do something with the keyframes

If we add something to the data object in the second frame, we "salt" the decoration's hash and cause the diffing algorithm to consider the decoration object to be distinct:

let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "Hello" (code points 4 to 11)
      { kind: "TEXT", data: {}, from: 4, to: 11 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "World" (code points 15 to 21)
      { kind: "TEXT", data: { class: "salt" }, from: 15, to: 22 },
    ],
  },
];

// ... do something with the keyframes
123456789101112131415161718
let keyframes = [
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "Hello" (code points 4 to 11)
      { kind: "TEXT", data: {}, from: 4, to: 11 },
    ],
  },
  {
    code: `[\n  "Hello",\n  "World"\n]`,
    decorations: [
      // Highlights the code section "World" (code points 15 to 21)
      { kind: "TEXT", data: { class: "salt" }, from: 15, to: 22 },
    ],
  },
];

// ... do something with the keyframes

It will therefore no longer transition a single decoration between frames, but rather remove the decoration frame 1 and fade in a new yellow box:

[
  "Hello",
  "World"
]
[
  "Hello",
  "World"
]

This enables opt-in manual control over how decorations behave. In extremis, you could just add an ID to every decoration and exert maximum manual control, but the diffing algorithm should usually do something reasonable by default.