npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

svelte-highlight

v7.23.1

Published

Svelte component library for highlighting code using highlight.js

Readme

svelte-highlight

[![NPM][npm]][npm-url] npm

Syntax highlighting for Svelte using highlight.js.

Try it in the Svelte REPL or StackBlitz.

Documentation

The Changelog is available on GitHub.

Installation

# npm
npm i svelte-highlight

# pnpm
pnpm i svelte-highlight

# Bun
bun i svelte-highlight

# Yarn
yarn add svelte-highlight

Basic Usage

The Highlight component requires two props:

  • code: text to highlight
  • language: language grammar used to highlight the text

Import languages from svelte-highlight/languages.

See SUPPORTED_LANGUAGES.md for a list of supported languages.

code accepts any value and is coerced with String(code ?? ""): an object like {} renders as "[object Object]", while undefined/null render as nothing.

<script>
  import Highlight from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";

  const code = "const add = (a: number, b: number) => a + b;";
</script>

<Highlight language={typescript} {code} />

Wrapping Long Lines

By default, overflowing horizontal content is contained by a scrollbar. Set wrap to true to wrap long lines instead.

<Highlight wrap language={typescript} {code} />

Don't combine wrap with HighlightStream's virtualize mode or HighlightVirtual -- both assume one fixed-height line per row, which wrapping breaks.

Theming

Every highlight.js theme is also compiled into a ThemePalette: a typed object of --shl-* CSS custom properties plus a shared structural stylesheet (svelte-highlight/themes/base.css) that maps .hljs* classes to those variables. This is the recommended way to theme new projects — themes are data, scoping is an inline style attribute (SSR-safe by construction), and any token is overridable with one CSS line or a Svelte style prop. See SUPPORTED_THEMES.md for a list of compiled themes.

The legacy string-theme path (svelte-highlight/styles, below) keeps working unchanged and is unaffected by any of this.

Global theme, CSS only (no JS in the bundle for theming):

<script>
  import "svelte-highlight/themes/base.css";
  import "svelte-highlight/themes/atom-one-dark.css";
</script>

Scoped themes — multiple themes coexisting, zero runtime cost:

<script>
  import "svelte-highlight/themes/base.css";
  import "svelte-highlight/themes/github.css";
  import "svelte-highlight/themes/atom-one-dark.css";
</script>

<div data-shl-theme="github"><Highlight ... /></div>
<div data-shl-theme="atom-one-dark"><Highlight ... /></div>

Component-driven scoping via palette objects (replaces the scoped-<style> machinery described under Scoping styles below — no injected <style> tag, no runtime CSS parsing):

<script>
  import { Highlight, HighlightStyle } from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";
  import atomOneDark from "svelte-highlight/themes/atom-one-dark";
  import "svelte-highlight/themes/base.css";

  const code = "const add = (a: number, b: number) => a + b;";
</script>

<HighlightStyle theme={atomOneDark}>
  <Highlight language={typescript} {code} />
</HighlightStyle>

This renders SSR-identical output — no <svelte:head>, no <style> tag, just inline vars on the wrapper:

<div style="--shl-fg:#abb2bf;--shl-bg:#282c34;--shl-keyword:#c678dd;...">...</div>

Dual light/dark via light-dark():

<HighlightStyle light={cyanotypeLight} dark={cyanotypeDark} mode="auto">
  <Highlight language={typescript} {code} />
</HighlightStyle>

The wrapper's inline style carries the plain light value for each var (e.g. --shl-keyword:#a626a4) — a baseline every browser can render. A scoped, deduped <style> tag (gated behind @supports (color: light-dark(...))) overrides it with --shl-keyword: light-dark(#a626a4, #c678dd) (etc.) plus color-scheme: light dark on browsers that support light-dark(). mode="light" / mode="dark" force color-scheme: light / dark. Any other mode string (the legacy "CSS selector" mode) omits color-scheme inline — set it on your own selector for app-controlled switching, e.g. [data-theme="dark"] { color-scheme: dark }.

See it live, with a mode toggle, on the dual-palette preview page.

One-line customization — any token, no theme forking:

[data-shl-theme="atom-one-dark"] {
  --shl-comment: #7f8c98;
}

Per-instance customization via Svelte style props (works automatically since every themed value routes through a var):

<Highlight --shl-keyword="hotpink" --shl-bg="#111" language={typescript} {code} />

Themes are data — derive a variant with a plain object spread:

const custom = {
  ...atomOneDark,
  vars: { ...atomOneDark.vars, "--shl-keyword": "#ff79c6" },
};

HighlightEditable's "css-highlights" engine accepts palettes too:

<HighlightEditable engine="css-highlights" theme={atomOneDark} language={typescript} bind:code />

The --shl-* variable contract

Variable names are derived mechanically from the selectors found in highlight.js themes. color is unsuffixed (it dominates — ~90% of declarations); every other supported property gets a suffix.

| Theme selector | Custom property | | --- | --- | | .hljs color | --shl-fg | | .hljs background / background-color | --shl-bg | | .hljs-keyword color | --shl-keyword | | .hljs-keyword background(-color) | --shl-keyword-bg | | .hljs-keyword font-style | --shl-keyword-font-style | | .hljs-keyword font-weight | --shl-keyword-font-weight | | .hljs-keyword text-decoration | --shl-keyword-text-decoration | | Compound .hljs-title.class_ | --shl-title-class_ (scope names joined with -; hljs's trailing underscores are kept) | | Descendant .hljs-meta .hljs-keyword | --shl-meta-keyword |

themes/base.css wraps a multi-scope (compound/descendant) variable in a fallback to its subject scope's variable — e.g. .hljs-meta .hljs-string { color: var(--shl-meta-string, var(--shl-string)); } — so a theme that never styles that specific combination still falls through to the plain scope color instead of rendering unstyled. Declarations that don't fit the var contract (gradients, borders, ::selection, etc.) ship as an opaque extras string on the palette, applied only via the .css artifact.

base.css assumes the default hljs- class prefix; projects using a custom classPrefix should stay on the legacy string path below.

HighlightStyle also inlines color-scheme from the palette(s) it's given — palette.colorScheme for a single theme, or a value derived from mode for a light/dark pair — so native form controls and scrollbars inside a themed block follow the theme too.

extras isn't a corner case for every theme — e.g. the 3024 theme's extras covers 7 declarations, including .hljs ::selection, .hljs::selection, .hljs-operator, and .ruby .hljs-property, all silently dropped when that theme is imported as a ThemePalette object (svelte-highlight/themes/3024) instead of its .css artifact. There's no per-theme fidelity indicator today; SUPPORTED_THEMES.md is where one could eventually live.

Creating themes

svelte-highlight/theme exports a typed, programmatic way to build or derive ThemePalette objects, instead of hand-writing --shl-* vars against the full scope vocabulary.

defineTheme() builds a complete palette from a small typed definition: a ~14-key semantic role layer (pick a dozen colors, get a full theme) plus optional per-scope precision overrides.

import { defineTheme } from "svelte-highlight/theme";

const midnight = defineTheme({
  name: "midnight",
  roles: {
    background: "#0b1021",
    foreground: "#d6deeb",
    comment: { color: "#637777", fontStyle: "italic" },
    keyword: "#c792ea",
    string: "#ecc48d",
    literal: "#f78c6c",
    function: "#82aaff",
    type: "#ffcb8b",
    variable: "#addb67",
    tag: "#7fdbca",
  },
  scopes: {
    "title.class_": "#ffd700", // per-scope precision on top of roles
    "meta keyword": "#5f7e97",
  },
});
<HighlightStyle theme={midnight}>
  <Highlight language={typescript} {code} />
</HighlightStyle>

roles are foreground, background, comment, keyword, string, literal, function, type, variable, property, tag, punctuation, meta, addition, deletion — each expands to a documented set of --shl-* scope vars. A bare string is shorthand for { color: string }; both roles and scopes also accept a { color, background, fontStyle, fontWeight, textDecoration } object. Precedence is extends palette vars → roles → scopes (later layers win per-var, not per-role). foreground/background are required unless extends is given. ROLE_SCOPES is exported for tooling that wants to enumerate exactly which scopes a role expands to.

extendTheme() derives a new palette from any shipped or user palette with role- or scope-level overrides:

import atomOneDark from "svelte-highlight/themes/atom-one-dark";
import { extendTheme } from "svelte-highlight/theme";

const branded = extendTheme(atomOneDark, {
  name: "atom-one-dark-branded",
  roles: { keyword: "#ff79c6" },
});

paletteToCss() emits any palette as a CSS string — for build-time/global CSS instead of the HighlightStyle component:

import { paletteToCss } from "svelte-highlight/theme";

writeFileSync("public/midnight.css", paletteToCss(midnight));

defineTheme output is a plain ThemePalette — spreadable, serializable, diffable, and valid everywhere shipped palettes work, per Themes are data above.

validatePalette() self-checks a ThemePalette — useful for a hand-built or hand-edited palette, e.g. one assembled via the Themes are data spread pattern above:

import { validatePalette } from "svelte-highlight/theme";

const custom = {
  ...atomOneDark,
  vars: { ...atomOneDark.vars, "--shl-keyword": "#ff79c6" },
};

validatePalette(custom); // -> [] when clean; otherwise a list of issues

It flags a missing/malformed vars object, a missing --shl-fg/--shl-bg, a vars key outside the --shl-* grammar, and a --shl-fg/--shl-bg value that doesn't look like a recognized color — never throws. defineTheme already runs it (plus an unknown-scopes-key check) automatically in dev mode.

Importing VS Code themes

svelte-highlight/theme/textmate imports VS Code / TextMate theme JSON (thousands of existing editor themes) into a ThemePalette:

import { fromTextMate } from "svelte-highlight/theme/textmate";
import nightOwl from "./night-owl-color-theme.json" with { type: "json" };

const palette = fromTextMate(nightOwl);

fromTextMate accepts a parsed object only — VS Code themes are often JSONC, so parsing is the caller's job. The scope mapping is best-effort: TextMate scope selectors are matched against a starter table by segment-prefix ("entity.name.function" matches "entity.name.function.method.ts"), with the most specific (longest) match winning ties broken by the later tokenColors entry — the same specificity semantics VS Code itself uses. The starter table covers common scopes, including Markdown code spans/fences and invalid/illegal markers; because matching generalizes by prefix, a row like "keyword" already resolves any more-specific scope sharing that prefix, so "keyword.control" and "keyword.operator" resolve differently without needing a dedicated row for each. Entries that don't map to anything are never silently dropped; pass onWarn to observe them:

const palette = fromTextMate(nightOwl, {
  onWarn: (message) => console.warn(message),
});

To count unmapped entries instead of observing each one as it happens, use fromTextMateWithWarnings, which aggregates every onWarn call:

import { fromTextMateWithWarnings } from "svelte-highlight/theme/textmate";

const { palette, warnings } = fromTextMateWithWarnings(nightOwl);
// warnings.length is the number of unmapped tokenColors entries

When present, theme.semanticTokenColors is read as a higher-priority overlay on top of tokenColors — matching VS Code's own precedence when semantic highlighting is enabled. It's resolved against a starter table of VS Code's default semantic token types (namespace, class, interface, enum, struct, typeParameter, type, parameter, variable, enumMember, property, event, decorator, label, function, method, macro, comment, string, keyword, number, regexp, operator); modifiers ("variable.readonly") and language scoping ("variable:typescript") are not resolved — only the base type before the first . or : is matched.

fromTextMate never populates ThemePalette.extras — editor.background is always read as a single solid color assigned straight to --shl-bg. This matches VS Code's own model (VS Code doesn't support gradients for editor.background either), so it's a deliberate, low-risk scope cut rather than a gap to fix.

Real VS Code theme files are usually JSONC (comments, trailing commas), which fromTextMate doesn't accept directly. scripts/import-textmate-theme.ts is a local dev recipe — not an installed CLI — that strips comments/trailing commas and writes the resulting ThemePalette as JSON; copy it into your own project and adapt as needed:

bun scripts/import-textmate-theme.ts ./night-owl-color-theme.json ./night-owl.palette.json

Styling

Import styles from svelte-highlight/styles. See SUPPORTED_STYLES.md for a list of supported styles. For new projects, prefer Theming above — this is the original, string-based theming path, kept fully supported for backward compatibility. Legacy styles are stable and will not be removed. One concrete reason to pick this path over ThemePalette: base.css assumes the default hljs- class prefix, so projects using a custom classPrefix should stay here. 45 of the styles (90 dark/light files) are original pairs authored by svelte-highlight rather than ported from highlight.js, marked as custom in the gallery and in SUPPORTED_STYLES.md.

There are two ways to apply highlight.js styles.

  1. Injected styles through svelte:head
  2. CSS StyleSheets

Injected Styles

This component exports highlight.js themes in JavaScript. Import the theme from svelte-highlight/styles and inject it using the svelte:head API.

<script>
  import Highlight from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";
  import github from "svelte-highlight/styles/github";

  const code = "const add = (a: number, b: number) => a + b;";
</script>

<svelte:head>
  {@html github}
</svelte:head>

<Highlight language={typescript} {code} />

Under a strict Content Security Policy without 'unsafe-inline' for style-src, the browser silently drops this injected <style> tag with no visible error. Use CSS StyleSheet below instead, which is CSP-safe by construction since it's a normal stylesheet reference.

This snippet also has no protection against duplicate <style> tags: placing it inside a component that's mounted more than once emits one <style> tag per mount. For anything mounted more than once, use HighlightStyle instead, which dedupes automatically across instances of the same theme.

CSS StyleSheet

Depending on your set-up, importing a CSS StyleSheet in Svelte may require a CSS file loader. SvelteKit/Vite automatically supports this. For Webpack, refer to examples/webpack.

<script>
  import { Highlight } from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";
  import "svelte-highlight/styles/github.css";

  const code = "const add = (a: number, b: number) => a + b;";
</script>

<Highlight language={typescript} {code} />

Linking from a CDN

CSS StyleSheets can also be externally linked from a Content Delivery Network (CDN) like unpkg.com.

[!WARNING] Using a CDN is best suited for prototyping and not recommended for production use.

HTML

<head>
  <link
    rel="stylesheet"
    href="https://unpkg.com/svelte-highlight/styles/github.css"
  />
</head>

svelte:head

<svelte:head>
  <link
    rel="stylesheet"
    href="https://unpkg.com/svelte-highlight/styles/github.css"
  />
</svelte:head>

Scoping styles

Themes target global .hljs selectors. Add more than one and the last injected wins. That works when every block on the page shares one theme.

Reach for scoped styles when blocks on the same page need different themes: a style gallery, a side-by-side comparison, or a light snippet next to a dark one.

Wrap each Highlight in HighlightStyle to keep a theme on that block:

<script>
  import { Highlight, HighlightStyle } from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";
  import a11yDark from "svelte-highlight/styles/a11y-dark";
  import github from "svelte-highlight/styles/github";

  const code = "const add = (a: number, b: number) => a + b;";
</script>

<HighlightStyle theme={a11yDark}>
  <Highlight language={typescript} {code} />
</HighlightStyle>

<HighlightStyle theme={github}>
  <Highlight language={typescript} {code} />
</HighlightStyle>

The component prefixes each selector with a scope class on the wrapper, so the theme only hits markup inside it.

theme is required. scopeClass defaults to a hash of the theme string. Pass your own, or bind it (bind:scopeClass) / read it from the slot (let:scopeClass). An empty string is treated the same as omitting scopeClass — it falls back to the auto-hash. scopeClass must be a single valid CSS class name; a value like "a b" will warn in the console and won't scope as expected.

For a page-wide theme, use {@html theme} as shown above.

Under a style-src CSP that requires a nonce, pass nonce and it's attached to the <style> tag HighlightStyle injects into <svelte:head>:

<HighlightStyle theme={a11yDark} nonce={cspNonce}>
  <Highlight language={typescript} {code} />
</HighlightStyle>

A single ThemePalette (theme as an object, not a light/dark pair) never injects a <style> tag at all — only inline vars on the wrapper — so it's unaffected by a style-src nonce requirement in the first place; that's a reason to prefer it under a strict CSP.

Known limitation: on the CSS-string path, server-side rendering emits one <style> tag per HighlightStyle instance, even when several instances on the page share the exact same theme — the client-side dedupe only kicks in after hydration. This is harmless (CSS is idempotent), but adds extra bytes on pages with many repeated identical-theme instances. The ThemePalette object path has no such concern for a single theme, since it never injects a <style> tag to begin with.

Dark mode

HighlightStyle can emit a light and a dark theme together and switch between them. Pass light and dark instead of theme:

<script>
  import { Highlight, HighlightStyle } from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";
  import github from "svelte-highlight/styles/github";
  import githubDark from "svelte-highlight/styles/github-dark";

  const code = "const add = (a: number, b: number) => a + b;";
</script>

<HighlightStyle light={github} dark={githubDark}>
  <Highlight language={typescript} {code} />
</HighlightStyle>

The mode prop controls how the two themes are switched (default "auto"):

  • "auto" — wrap each theme in a @media (prefers-color-scheme: …) query so the OS/browser preference decides.
  • "light" / "dark" — emit only that single theme.
  • any other string — treated as a CSS selector that gates the dark block while light stays the default. Use this to drive theming from a class or attribute on an ancestor, e.g. a manual theme toggle:
<HighlightStyle light={github} dark={githubDark} mode={'[data-theme="dark"]'}>
  <Highlight language={typescript} {code} />
</HighlightStyle>

Both themes are scoped to the wrapper, so different blocks can use different theme pairs on the same page. When light and dark are both set they take precedence over theme; passing only theme keeps the existing single-theme behavior unchanged.

The ThemePalette object pair path (light/dark as ThemePalettes rather than CSS strings) works the same way, but resolves the pair via light-dark() instead of prefers-color-scheme/selector gating. On a browser that predates light-dark() support, it now falls back to the light theme instead of rendering broken (unset) colors: a scoped, deduped <style> tag applies the light-dark() values behind @supports (color: light-dark(...)), and the wrapper's own inline style carries the plain light value as the pre-@supports baseline. Single-theme (theme alone, no pair) SSR-identical guarantees are unaffected either way.

Styling with CSS variables

Every Highlight variant forwards $$restProps to its top-level element, so you can style the component using CSS variables without resorting to :global overrides or !important.

<Highlight
  language={typescript}
  {code}
  --border-radius="8px"
  --max-width="40rem"
/>

Container variables

These apply to the outer container of every component (the pre element for Highlight, HighlightAuto, HighlightSvelte, and LangTag; the div element for LineNumbers).

| Variable | Description | Default value | | :-------------- | :----------------------------------------- | :------------ | | --border-radius | Border radius of the container | 0 | | --width | Width of the container | auto | | --max-width | Maximum width of the container | none | | --overflow-x | Horizontal overflow behavior | auto | | --overflow-y | Vertical overflow behavior | auto |

[!NOTE] Because the container clips its content (overflow is not visible by default), setting --border-radius rounds the visible corners without any extra CSS.

LineNumbers variables

| Variable | Description | Default value | | :------------------------ | :------------------------------------------------ | :--------------------------- | | --line-number-color | Text color of the line numbers | currentColor | | --line-number-digit-width | Width of a single gutter digit | 0.6em | | --border-color | Border color of the column of line numbers | currentColor | | --highlighted-background | Background color of highlighted lines | rgba(254, 241, 96, 0.2) | | --line-added-background | Background color of lineStates "added" lines | rgba(46, 204, 113, 0.15) | | --line-removed-background | Background color of lineStates "removed" lines | rgba(231, 76, 60, 0.15) | | --unhighlighted-opacity | Opacity of un-highlighted lines (focus mode) | 1 | | --unhighlighted-filter | CSS filter for un-highlighted lines (focus mode) | none | | --padding | Fallback padding for --padding-left / --padding-right | 1em | | --padding-left | Left padding for td elements | var(--padding, 1em) | | --padding-right | Right padding for td elements | var(--padding, 1em) |

CopyButton variables

| Variable | Description | Default value | | :------------------- | :----------------------------------- | :------------ | | --copy-top | Top offset of the button | 0.5em | | --copy-right | Right offset of the button | 0.5em | | --copy-size | Width and height of the button | 2em | | --copy-padding | Inner padding of the button | 0.5em | | --copy-background | Background color of the button | inherit | | --copy-color | Foreground color of the button | inherit | | --copy-border-radius | Border radius of the button | 4px | | --copy-border | Border of the button | none | | --copy-z-index | Stacking order of the button | 2 |

Language tag variables

These apply when langtag is set to true.

| Variable | Description | Default value | | :---------------------- | :------------------------------ | :------------ | | --langtag-top | Top position of the langtag | 0 | | --langtag-right | Right position of the langtag | 0 | | --langtag-background | Background color of the langtag | inherit | | --langtag-color | Text color of the langtag | inherit | | --langtag-border-radius | Border radius of the langtag | 0 | | --langtag-padding | Padding of the langtag | 1em | | --langtag-font-size | Font size of the langtag | inherit |

CodeWindow variables

| Variable | Description | Default value | | :-------------------- | :--------------------------------------- | :---------------------------------- | | --window-background | Background color of the window body | #1e1e1e | | --window-border | Border of the window | 1px solid rgba(255, 255, 255, 0.1)| | --window-radius | Corner radius of the window | 0 | | --titlebar-gap | Gap between items in the title bar | 0.5em | | --titlebar-padding | Padding of the title bar | 0.65em 1em | | --titlebar-background | Background color of the title bar | #2d2d2d | | --titlebar-border | Bottom border of the title bar | 1px solid rgba(255, 255, 255, 0.1)| | --titlebar-color | Text color of the title bar | rgba(255, 255, 255, 0.6) | | --titlebar-font-family| Font family of the title bar | system-ui, -apple-system, sans-serif| | --titlebar-font-size | Font size of the title bar | 0.8125em | | --dot-gap | Gap between the macOS traffic-light dots | 0.5em | | --dot-size | Diameter of the macOS traffic-light dots | 0.75em | | --dot-close | Color of the "close" traffic-light dot | #ff5f56 | | --dot-minimize | Color of the "minimize" traffic-light dot| #ffbd2e | | --dot-maximize | Color of the "maximize" traffic-light dot| #27c93f | | --prompt-font-family | Font family of the terminal prompt | ui-monospace, monospace | | --prompt-font-weight | Font weight of the terminal prompt | 700 | | --prompt-color | Color of the terminal prompt | inherit |

FileTabs variables

| Variable | Description | Default value | | :---------------------- | :--------------------------------------------------------------- | :-------------------------------------------- | | --file-tabs-gap | Gap between tabs | 0 | | --file-tabs-background | Tab strip background | inherit | | --tab-overflow-fade | Width of the scroll shadow shown on the overflowing edge | 1.5rem | | --tab-padding | Padding of each tab | 0.5em 1em | | --tab-color | Text color of inactive tabs | inherit | | --tab-background | Background color of inactive tabs | transparent | | --tab-inactive-opacity | Opacity of inactive tabs (active and hovered tabs stay opaque) | 0.55 | | --tab-active-color | Text color of the active tab | the highlighted code's text color | | --tab-active-background | Background color of the active tab | the highlighted code's background | | --tab-focus-outline | Keyboard focus outline of a tab | 2px solid currentColor |

Svelte Syntax Highlighting

Use the HighlightSvelte component for Svelte syntax highlighting. Its grammar understands Svelte 5 runes ($state, $derived, $effect and its suffixed forms, $props.id, $inspect.trace), store auto-subscription ($store, $store(), $store.prop), lang="ts"/context="module" script-block resolution, and directive shorthand (on:, bind:, use:, and friends) — genuinely ahead of generic HTML-plus-embedded-JS Svelte highlighting, which has no notion that runes exist.

<script>
  import { HighlightSvelte } from "svelte-highlight";
  import github from "svelte-highlight/styles/github";

  const code = `<script>
  let count = $state(0);
  let doubled = $derived(count * 2);
<\/script>

<button on:click={() => count++}>{doubled} (store: {$externalCount})</button>`;
</script>

<svelte:head>
  {@html github}
</svelte:head>

<HighlightSvelte {code} />

Nested template declaration tags are also supported — including a second, shadowing {const ...} declared inside a nested element within the same {#each} block.

See the kitchen-sink live demo for a fuller tour of Svelte highlighting across every theme.

Auto-highlighting

The HighlightAuto component uses the highlightAuto API and attempts to guess what grammar to use based on the provided code. Its on:highlight event's secondBest field surfaces the runner-up candidate and its relevance, for showing detection confidence.

[!WARNING] Mounting HighlightAuto statically imports all 279 shipped grammars, regardless of languageNames, so bundle size is unconditionally larger than a Highlight with a single named language. Specify a language if possible.

<script>
  import { HighlightAuto } from "svelte-highlight";
  import github from "svelte-highlight/styles/github";

  const code = `body {\n  padding: 0;\n  color: red;\n}`;
</script>

<svelte:head>
  {@html github}
</svelte:head>

<HighlightAuto {code} />

Limiting Language Detection

You can restrict language auto-detection to a subset using the languageNames prop. This can improve performance and accuracy — it narrows the candidates scored during detection, but it does not reduce bundle size, since all shipped grammars are imported unconditionally (see the warning above). A grammar registered with disableAutodetect: true stays excluded from detection even when explicitly named in languageNames.

<script>
  import { HighlightAuto } from "svelte-highlight";
  import github from "svelte-highlight/styles/github";

  const code = "const x = 42;";
</script>

<svelte:head>
  {@html github}
</svelte:head>

<HighlightAuto {code} languageNames={["javascript", "typescript"]} />

Line Numbers

Use the LineNumbers component to render the highlighted code with line numbers.

<script>
  import Highlight, { LineNumbers } from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";
  import atomOneDark from "svelte-highlight/styles/atom-one-dark";

  const code = "const add = (a: number, b: number) => a + b";
</script>

<svelte:head>
  {@html atomOneDark}
</svelte:head>

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers {highlighted} />
</Highlight>

Hidden Border

Set hideBorder to true to hide the border of the line numbers column.

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers {highlighted} hideBorder />
</Highlight>

Wrapped Lines

By default, overflowing horizontal content is contained by a scrollbar.

Set wrapLines to true to hide the border of the line numbers column.

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers {highlighted} wrapLines />
</Highlight>

Custom Starting Line Number

The line number starts at 1. Customize this via the startingLineNumber prop.

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers {highlighted} startingLineNumber={42} />
</Highlight>

Windowed Lines

LineNumbers re-splits the entire highlighted string into lines on every update, which is wasteful once you're already rendering a window out of a much larger document. Pass a pre-split lines array instead -- the same per-line HTML shape svelte-highlight/tokenized-document's lineRange (see Large documents) and HighlightStream's incremental rendering already produce -- and set lineCount to the total document line count so the gutter is sized for the whole document instead of jittering as the window scrolls. startingLineNumber offsets the row numbers to match the window's position; highlightedLines/lineStates index relative to the lines array actually rendered, not the absolute document line.

<script>
  import { LineNumbers } from "svelte-highlight";
  import { createTokenizedDocument } from "svelte-highlight/tokenized-document";
  import typescript from "svelte-highlight/languages/typescript";

  const doc = createTokenizedDocument({ language: typescript });
  doc.setCode(bigFile);

  const start = 50_000;
  const end = 50_040;
</script>

<LineNumbers
  lines={doc.lineRange(start, end)}
  startingLineNumber={start + 1}
  lineCount={doc.lineCount()}
/>

Highlighted Lines

Specify the lines to highlight using the highlightedLines prop. Indices start at zero.

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers {highlighted} highlightedLines={[0, 2, 3, 14]} />
</Highlight>

Use --unhighlighted-opacity or --unhighlighted-filter to de-emphasize the remaining lines and focus attention on the highlighted ones. Both only take effect when highlightedLines is non-empty.

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers
    {highlighted}
    highlightedLines={[0, 2, 3, 14]}
    --unhighlighted-opacity="0.4"
  />
</Highlight>

Use --highlighted-background to customize the background color of highlighted lines.

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers
    {highlighted}
    highlightedLines={[0, 2, 3, 14]}
    --highlighted-background="#000"
  />
</Highlight>

Line States

highlightedLines only supports one visual treatment. Use lineStates for a per-line state map -- "highlighted" (same treatment as highlightedLines), "focus" (exempt from dimming, no background color), "added", or "removed" -- so diff-style and meta-string-style decorations can share one primitive. Indices start at zero and are merged with highlightedLines, which is equivalent to setting "highlighted" here.

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers
    {highlighted}
    lineStates={{ 1: "added", 2: "removed", 4: "focus" }}
  />
</Highlight>

Use --line-added-background and --line-removed-background to customize the background colors.

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers
    {highlighted}
    lineStates={{ 1: "added", 2: "removed" }}
    --line-added-background="rgba(0, 255, 0, 0.15)"
    --line-removed-background="rgba(255, 0, 0, 0.15)"
  />
</Highlight>

Custom Styles

Use --style-props to customize styles.

| Style prop | Description | Default value | | :------------------------ | :------------------------------------------------------ | :-------------------------- | | --line-number-color | Text color of the line numbers | currentColor | | --line-number-digit-width | Width of a single gutter digit | 0.6em | | --border-color | Border color of the column of line numbers | currentColor | | --padding | Fallback padding for --padding-left / --padding-right | 1em | | --padding-left | Left padding for td elements | var(--padding, 1em) | | --padding-right | Right padding for td elements | var(--padding, 1em) | | --highlighted-background | Background color of highlighted lines | rgba(254, 241, 96, 0.2) | | --line-added-background | Background color of lineStates "added" lines | rgba(46, 204, 113, 0.15) | | --line-removed-background | Background color of lineStates "removed" lines | rgba(231, 76, 60, 0.15) | | --unhighlighted-opacity | Opacity of un-highlighted lines (focus mode) | 1 | | --unhighlighted-filter | CSS filter for un-highlighted lines (focus mode) | none |

See Styling with CSS variables for the full list, including container-level variables like --border-radius, --width, and --overflow-x.

<Highlight language={typescript} {code} let:highlighted>
  <LineNumbers
    {highlighted}
    --line-number-color="pink"
    --line-number-digit-width="0.75em"
    --border-color="rgba(255, 255, 255, 0.2)"
    --padding-left={0}
    --padding-right="3em"
    --highlighted-background="#000"
    --border-radius="8px"
    --max-width="40rem"
  />
</Highlight>

The gutter's position and border use logical CSS properties (inset-inline-start/inset-inline-end), so it follows dir="rtl" without any extra configuration.

Language Tag

When using a custom slot, forward langtag and languageName from Highlight to LineNumbers.

<Highlight
  language={typescript}
  {code}
  langtag
  let:highlighted
  let:langtag
  let:languageName
>
  <LineNumbers {highlighted} {langtag} {languageName} />
</Highlight>

With HighlightAuto:

<HighlightAuto {code} langtag let:highlighted let:langtag let:languageName>
  <LineNumbers {highlighted} {langtag} {languageName} />
</HighlightAuto>

Copy Button

Compose the CopyButton component alongside Highlight to add a copy-to-clipboard button. Wrap both in a relatively-positioned container so the button can be positioned over the code block.

By default, it copies the code using the native Clipboard API and shows a transient "copied" state. The button renders a copy icon that swaps to a checkmark on success; the text / copiedText props set the aria-label for each state. Clicks are ignored while in the "copied" state, so no duplicate copy fires.

<script>
  import { Highlight, CopyButton } from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";
  import atomOneDark from "svelte-highlight/styles/atom-one-dark";

  const code = "const add = (a: number, b: number) => a + b";
</script>

<svelte:head>
  {@html atomOneDark}
</svelte:head>

<div style="position: relative">
  <Highlight language={typescript} {code} />
  <CopyButton {code} />
</div>

Custom Copy Behavior

Pass a copy function to override the default copy behavior. It receives the code to copy and may be async (return a promise); the "copied" state is shown only once it resolves. Clicks are ignored while a copy is in flight, so a slow async copy can't fire duplicates.

<script>
  function copy(code) {
    navigator.clipboard.writeText(code);
    console.log("Copied:", code);
  }
</script>

<CopyButton {code} {copy} />

The component dispatches a copy event on success and an error event if the copy behavior throws.

<CopyButton
  {code}
  on:copy={(e) => console.log(e.detail.code)}
  on:error={(e) => console.error(e.detail.error)}
/>

Transforming copied text

Displayed code often carries decoration that shouldn't survive a copy -- shell prompts, REPL markers, diff signs. Pass a transform function to strip it before the copy function runs; on:copy reports the transformed string, not the original code.

stripPrompts and stripDiffMarkers cover the common cases and are exported for reuse (compose them for your own transform when a block needs both):

<script>
  import { CopyButton, stripPrompts } from "svelte-highlight";

  const code = "$ npm install\nadded 1 package";
</script>

<CopyButton {code} transform={stripPrompts} />

Custom Button Content

Provide custom button content using the default slot to replace the default icons. The slot exposes a copied boolean and a copying boolean (true while an async copy is in flight), so you can render a pending state for slow copy functions.

<CopyButton {code} let:copied let:copying>
  {#if copying}Copying…{:else if copied}Copied!{:else}Copy{/if}
</CopyButton>

Custom Styles

Use --copy-* style props to customize the button.

<CopyButton
  {code}
  --copy-background="rgba(255, 255, 255, 0.1)"
  --copy-color="#fff"
  --copy-border-radius="8px"
/>

With Line Numbers

Compose CopyButton with LineNumbers by wrapping both in a relatively-positioned container.

<div style="position: relative">
  <Highlight language={typescript} {code} let:highlighted>
    <LineNumbers {highlighted} />
  </Highlight>
  <CopyButton {code} />
</div>

With Language Tag

When using a language tag alongside CopyButton, offset --langtag-top and --langtag-right so the tag sits to the left of the button (the copy button defaults to --copy-top: 0.5em, --copy-right: 0.5em, and --copy-size: 2em).

<div style="position: relative">
  <Highlight
    language={typescript}
    {code}
    langtag
    --langtag-top="0"
    --langtag-right="3em"
    --langtag-padding="0.25em 0.5em"
    --langtag-font-size="0.75em"
  />
  <CopyButton {code} />
</div>

Editable

HighlightEditable is a contenteditable code block. It re-highlights on every edit and keeps the caret where you left it.

Use bind:code to keep your state in sync.

<script>
  import { HighlightEditable } from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";
  import atomOneDark from "svelte-highlight/styles/atom-one-dark";

  let code = "const add = (a: number, b: number) => a + b";
</script>

<svelte:head>
  {@html atomOneDark}
</svelte:head>

<HighlightEditable language={typescript} bind:code />

Keyboard Behavior

  • Enter inserts a newline.
  • Tab / Shift+Tab indent or dedent the selected lines (or insert one indent at the caret). Set the indent size with the tabSize prop (default 2).
  • Escape then Tab/Shift+Tab moves keyboard focus out of the editor instead of indenting, so the block never traps focus. Any other key clears this and restores normal Tab behavior.
  • Cmd/Ctrl+Z undo and Shift+Cmd/Ctrl+Z (or Ctrl+Y) redo. Typing bursts collapse into a single undo step; cap the retained history with the historyLimit prop (default 200).

Pasting and dragging content into the editor both insert plain text only, regardless of the source payload (rich text, HTML, etc.).

Dispatched Events

HighlightEditable dispatches change (on every edit), blur, and history (whenever the undo/redo stack changes).

<HighlightEditable
  language={typescript}
  bind:code
  on:change={(e) => console.log(e.detail.code)}
  on:blur={(e) => console.log(e.detail.code)}
  on:history={(e) => console.log(e.detail.canUndo, e.detail.canRedo)}
/>

Imperative API

Use bind:this to call methods on the instance.

<script>
  let editor;
</script>

<HighlightEditable bind:this={editor} language={typescript} bind:code />

<button on:click={() => editor.undo()}>Undo</button>
<button on:click={() => editor.redo()}>Redo</button>
<button on:click={() => editor.indent()}>Indent</button>
<button on:click={() => editor.clear()}>Clear</button>

Available methods: undo, redo, focus, selectAll, insert, indent, outdent, setCode, clear, getCode, canUndo, canRedo, and resolvedEngine.

Custom Styles

Customize the focus outline with the --outline-color, --outline-width, and --outline-offset style props.

<HighlightEditable
  language={typescript}
  bind:code
  --outline-color="#42be65"
  --outline-width="2px"
/>

Read-only

Set readonly to keep highlighting, caret placement, and selection active while blocking edits (typing, paste, drag-and-drop, and Tab/Shift+Tab indent). The imperative API still works, so a consumer can toggle a document read-only without swapping components.

<script>
  let readonly = false;
</script>

<HighlightEditable language={typescript} bind:code {readonly} />
<button on:click={() => (readonly = !readonly)}>
  {readonly ? "Unlock" : "Lock"}
</button>

Experimental: CSS Custom Highlight engine

engine="css-highlights" paints tokens with the CSS Custom Highlight API (CSS.highlights, ::highlight()) instead of wrapping them in <span>s. The editable <code> stays plain text (one <span> per line, reused from the default engine's line structure, but with no per-token spans inside), so a repaint never replaces the DOM the caret is sitting in.

Requires Chrome 105+, Safari 17.2+, or Firefox 140+. Where CSS.highlights is unavailable, HighlightEditable falls back to engine="dom" silently; check what actually ran with editor.resolvedEngine().

Pass theme (the same theme string you'd give HighlightStyle) to generate ::highlight() rules from it. Only color/background-color convert, and only for single-class .hljs-<scope> rules — compound (.hljs-title.class_) and descendant-selector (.hljs-meta .hljs-keyword) scopes have no ::highlight() equivalent and get no color at all, not just plain weight/style.

theme only covers token colors — you still need HighlightStyle (or an injected stylesheet) for the base .hljs layout rules (padding, overflow, background), exactly as with the default engine.

<script>
  import { HighlightEditable, HighlightStyle } from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";
  import github from "svelte-highlight/styles/github";

  let code = "const add = (a: number, b: number) => a + b";
</script>

<HighlightStyle theme={github}>
  <HighlightEditable language={typescript} bind:code engine="css-highlights" theme={github} />
</HighlightStyle>

Adding a gutter

HighlightEditable has no slot and doesn't expose highlighted/languageName, so it can't be wrapped directly in LineNumbers the way a static Highlight block can. Mirror the same code in a second, read-only Highlight instance wrapped in LineNumbers, and position it alongside the editable block:

<script>
  import { Highlight, HighlightEditable, LineNumbers } from "svelte-highlight";
  import typescript from "svelte-highlight/languages/typescript";

  let code = "const add = (a: number, b: number) => a + b";
</script>

<HighlightEditable language={typescript} bind:code />

<Highlight language={typescript} {code} let:highlighted let:languageName>
  <LineNumbers {highlighted} {languageName} />
</Highlight>

Language Targeting

All Highlight components apply a data-language attribute on the codeblock containing the language name.

See SUPPORTED_LANGUAGES.md for a list of supported languages.

[data-language="css"] {
  /* custom style rules */
}

Language Tags

The language tag is one badge (LangTag.svelte + langtag.css) reached three ways: automatically, via Highlight/HighlightAuto/HighlightSvelte's built-in fallback render when langtag is set; directly, via the standalone LangTag component; or via LineNumbers' own langtag/languageName props, which are not wired to a wrapping Highlight automatically and must be forwarded manually from slot props (see Language Tag under Line Numbers).

Set langtag to true to display the language name in the top right corner of the code block.

Customize the language tag background, color, and border-radius using style props.

Setting langtag opts a <Highlight> usage out of the svelte-highlight/static build-time transform, since static output can't pull in langtag.css.

If the highlighted output is empty (e.g. empty code, or code that highlights to an empty string), the badge and code block still render, but the code falls back to plain, unhighlighted text — the hljs class is still applied, there's just no syntax-highlighting markup inside it.

See the Languages page for a list of supported languages.

| Style prop | Description | Default value | | :---------------------- | :------------------------------ | :------------ | | --langtag-top | Top position of the langtag | 0 | | --langtag-right | Right position of the langtag | 0 | | --langtag-background | Background color of the langtag | inherit | | --langtag-color | Text color of the langtag | inherit | | --langtag-border-radius | Border radius of the langtag | 0 | | --langtag-padding | Padding of the langtag | 1em | | --langtag-font-size | Font size of the langtag | inherit |

<script>
  import { HighlightAuto } from "svelte-highlight";

  $: code = `.body { padding: 0; margin: 0; }`;
</script>

<HighlightAuto {code} langtag />
<HighlightAuto
  {code}
  langtag
  --langtag-background="linear-gradient(135deg, #2996cf, 80%, white)"
  --langtag-color="#fff"
  --langtag-border-radius="6px"
  --langtag-padding="0.5rem"
/>

Custom Language

A language is { name, register }, where register is a plain-JSON grammar — the same format every svelte-highlight/languages/* module ships, produced by converting an hljs-format grammar at build time.

The easiest way to author one is with svelte-highlight/compat's fromHighlightJs: write your grammar exactly as the hljs guide describes (a register(hljs) function returning a mode object), and convert it at runtime — no build step, no bundled hljs code.

<script>
  import { Highlight } from "svelte-highlight";
  import { fromHighlightJs } from "svelte-highlight/compat";

  function defineDotenv(hljs) {
    return {
      name: "dotenv",
      contains: [
        hljs.HASH_COMMENT_MODE,
        { className: "keyword", begin: /^[ \t]*export(?=[ \t])/ },
        { className: "attr", begin: /[A-Za-z_][A-Za-z0-9_]*(?=[ \t]*=)/ },
        {
          className: "string",
          variants: [
            { begin: /"/, end: /"/, contains: [hljs.BACKSLASH_ESCAPE] },
            { begin: /'/, end: /'/ },
          ],
        },
      ],
    };
  }

  const languagePromise = fromHighlightJs("dotenv", defineDotenv);
</script>

{#await languagePromise then language}
  <Highlight {language} code={'PORT=3000\n# comment'} />
{/await}

fromHighlightJs needs highlight.js itself, which svelte-highlight does not bundle — install it as your own dependency (npm install highlight.js). It's only imported the first time you call fromHighlightJs, so pages that never use it don't pay for it.

fromHighlightJs also accepts an optional third source argument (raw source text, e.g. via a bundler's ?raw import) to recover array-membership on:begin guards the compiled callback alone can't expose, and the returned object carries a warnings: string[] field (empty when clean) so you can detect a degraded conversion without reading the console.

const language = await fromHighlightJs("dotenv", defineDotenv, grammarSourceText);
if (language.warnings.length > 0) {
  console.error("dotenv degraded:", language.warnings);
}

If you're using TypeScript, use the LanguageType interface to type the language.

import type { LanguageType } from "svelte-highlight";
import { fromHighlightJs } from "svelte-highlight/compat";

const language: LanguageType<"dotenv"> = await fromHighlightJs(
  "dotenv",
  (hljs) => ({
    name: "dotenv",
    contains: [
      hljs.HASH_COMMENT_MODE,
      { className: "attr", begin: /[A-Za-z_][A-Za-z0-9_]*(?=[ \t]*=)/ },
    ],
  }),
);

Building a package for others to install? Ship the converted grammar directly instead of calling fromHighlightJs at runtime — see scripts/convert-grammars.ts for the converter this package's own bundled languages go through.

Conversion fidelity

fromHighlightJs and scripts/convert-grammars.ts (the pipeline behind every one of the ~244 bundled svelte-highlight/languages/* grammars) share the exact same converter, so a custom grammar goes through identical logic to every shipped one, not a lesser path.

Most hljs grammars are plain-JSON contains/begin/end/className rules, which convert as-is. A grammar can also use on:begin, on:end, or __beforeBegin callbacks for cases regexes alone can't express; the converter recognizes six specific shapes and turns them into declarative flags:

  • An on:begin callback that copies the matched text so the closing delimiter must equal the opening one (heredocs).
  • An on:begin callback that only accepts a match at the very start of input (a shebang guard).
  • An on:begin callback that disambiguates an opening tag from a generic comparison (JSX).
  • An on:begin callback that checks the character immediately before the match (a letter-boundary guard).
  • An on:begin callback checking SOME_SET.has(match[0]) against a module-level word list — recovered only when you also pass the grammar's own source text as fromHighlightJs's third argument, since the compiled callback alone doesn't expose the backing array.
  • A __beforeBegin callback that rejects a match immediately following a . (hljs's beginKeywords dot-guard).

Any other on:begin/on:end/__beforeBegin body converts too, but with a console.warn and the rule's relevance forced to zero — it still matches, unconditionally, without skewing auto-detection. Check language.warnings (see above) to catch this without reading the console.

Upgrading a custom language written before 7.16? Before 7.16, a custom language was { name, register: (hljs) => modeObject } passed synchronously straight to <Highlight>'s language prop. Since 7.16 replaced the highlight.js runtime with this package's own engine, register must be plain-JSON grammar IR instead, and fromHighlightJs (now async, requiring {#await}) is the replacement shown above.

Custom Plugin

Third-party hljs language plugins work the same way: pass their register(hljs) export to fromHighlightJs.

This example uses the cURL language plugin.

<script>
  import { Highlight } from "svelte-highlight";
  import { fromHighlightJs } from "svelte-highlight/compat";
  import curl from "highlightjs-curl";
  import github from "svelte-highlight/styles/github";

  const languagePromise = fromHighlightJs("curl", curl);

  const code = `curl -X POST "https://api.example.com/data" \\
     -H "Content-Type: application/json" \\
     -d '{"key": "value"}'`;
</script>

<svelte:head>
  {@html github}
</svelte:head>

{#await languagePromise then language}
  <Highlight {language} {code} />
{/await}

Code-splitting

You can use the await import syntax for code-splitting.

In the example below, the HighlightAuto component and injected styles are dynamically loaded.

<script>
  import { onMount } from "svelte";

  let component;
  let styles;

  onMount(async () => {
    component = (await import("svelte-highlight")).HighlightAuto;
    styles = (await import("svelte-highlight/styles/github")).default;
  });
</script>

<svelte:head>
  {#if styles}
    {@html styles}
  {/if}
</svelte:head>

<svelte:component
  this={component}
  langtag
  code={`body {\n  padding: 0;\n  color: red;\n}`}
/>

Registering a grammar checks its canonical name, not an alias lookup, before skipping — so a grammar that aliases another (highlight.js's "ini" aliases "toml") can't make the real grammar look already-registered before it has actually loaded.

Loading a language by name

The example above imports a specific language as a static string, which lets the bundler split out only the grammars you reference. When the language is known only at runtime — a Markdown fence (```ts), an API field, or a user-selected value — use the loadLanguage helper to import a grammar by name:

<script>
  import { Highlight, loadLanguage } from "svelte-highlight";
  import github from "svelte-highlight/styles/github";

  // The language name is not known until runtime.
  export let language = "typescript";
  export let code = "const add = (a, b) => a + b;";
</script>

<svelte:head>
  {@html github}
</svelte:head>

{#await loadLanguage(language) then grammar}
  <Highlight {code} language={grammar} />
{:catch}
  <pre>{code}</pre>
{/await}

loadLanguage accepts a supported language name, dynamically imports its grammar, and resolves with the language object. It rejects with a LanguageLoadError for an unrecognized name, so handle the {:catch} block (or .catch) when the name comes from untrusted input. LanguageLoadError is importable from svelte-highlight for an instanceof check:

import { LanguageLoadError } from "svelte-highlight";

try {
  const grammar = await loadLanguage(name);
} catch (error) {
  if (error instanceof LanguageLoadError) {
    // handle a missing/misspelled language name
  }
}

Grammars with embedded sublanguages (astro, svelte, and others with a dependencies list) register their dependencies automatically — Highlight, HighlightAuto, and HighlightSvelte all call ensureRegistered on the language they're given, which registers the grammar and recurses through its dependencies.

loadLanguage does a fresh import() on every call and keeps no explicit cache, but it's safe to call repeatedly for the same name: module loaders dedupe dynamic imports by resolved specifier rather than re-fetching, so repeated calls resolve to the same cached module.

Note: Because the imported name is dynamic, the bundler cannot prune unused grammars and will emit a chunk for every language — all 279 shipped grammars. Prefer a static import when the language is known ahead of time, and reach for loadLanguage only when it is not.

A practical case is rendering Markdown, where each fenced block declares its own language — see "Rendering Markdown/MDX fences" below for a ready-made highlightFence helper built on top of loadLanguage.

Static mode

svelte-highlight/static is a Svelte preprocessor. At build time it replaces <Highlight> usages that have known code and language with pre-rendered highlight.js HTML, so the client never downloads highlight.js or that grammar module.

The biggest win is on server-rendered or statically-generated pages, since a client-only SPA still ships the pre-rendered markup as a string inside the JS bundle, just without highlight.js and the grammar module.

// vite.config.js
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { highlightStatic } from "svelte-highlight/static";

export default {
  plugins: [svelte({ preprocess: [highlightStatic()] })],
};
<script>
  import Highlight from "svelte-highlight";
  import javascript from "svelte-highlight/languages/javascript";
</script>

<Highlight language={javascript} code="const x = 1;" />

At build time this becomes plain HTML:

<pre class="hljs" data-language="javascript"><code class="hljs"
  ><span class="hljs-keyword">const</span> x = <span class="hljs-number">1</span>;</code
></pre>

The static transform runs only when all of these are true:

  • language is a plain identifier from a static import of svelte-highlight/languages/*.
  • code is a string literal or a template literal with no ${...} interpolation.
  • The element has no slot content, spread props, or directives (bind:, on:, etc.).
  • langtag, if set, is a bare flag or a static boolean literal (langtag={true}/langtag={false}) - not a variable or expression. Static output can't pull in langtag.css (that only happens when a runtime LangTag is in the bundle), so a truthy langtag renders the language badge as a plain inline-styled <span> instead, themed with the same --langtag-* custom properties LangTag reads - customization is limited to those variables, not LangTag's slot content.

Dynamic code or language, loadLanguage, HighlightAuto, HighlightSvelte, and HighlightEditable keep the runtime component. No warning. That's intentional.

If a usage looks static but still fails (language module missing, highlight.js throws), it falls back to the runtime component and calls onWarn. Usually that's a typo or a resolution quirk, not a dynamic usage you meant to keep:

highlightStatic({
  onWarn(message, { filename, line, cause }) {
    // defaults to `console.warn(`[svelte-highlight/static] ${filename}:${line} - ${message}`)`
  },
});

To measure how many <Highlight> usages a build actually converted to static markup, pass onSummary. It fires once per file with at least one shape-matched usage (files with none don't trigger it), so summing across calls gives a build-wide total:

let matched = 0;
let succeeded = 0;

highlightStatic({
  onSummary(summary) {
    matched += summary.matched;
    succeeded += summary.succeeded;
  },
});

Scope is small on purpose. Extra props on <Highlight> don't carry over to the emitted <pre>. Unused Highlight and language imports are left in place; bundlers drop them (sideEffects in package.json is narrow enough).

Rendering Markdown/MDX fences

highlightStatic only transforms literal <Highlight> usages in .svelte files. Markdown/MDX/mdsvex code fences never go through the Svelte compiler at all, so svelte-highlight/fence provides a separate, framework-agnostic building block: parseMeta(meta) and highlightFence({ code, lang, meta }), a plain async function with zero Svelte dependency.

import { highlightFence } from "svelte-highlight/fence";

const html = await highlightFence({
  code: 'const add = (a, b) => a + b;\nconst sub = (a, b) => a - b;\nexport { add, sub };',
  lang: "typescript",
  meta: '{1,3-5} title="app.ts"',
});

lang accepts either the grammar's canonical file name or a known alias (e.g. "ts", "sh") -- `hig