CodeBlock
A headless, read-only code viewer with a copy button, an opt-in title and language header, decorative line numbers, and a Show-more collapse for long listings. It ships no syntax highlighter, so bring your own through the language class hook (client autoloaders) or the children escape hatch (build-time highlighters like Shiki).
Import
import { CodeBlock } from "@hyzer-labs/ui"Demo
With no title and no language, the copy button floats over the
top-right corner of the code.
function scoreToPar(round: { strokes: number }, par: number): number {
return round.strokes - par;
}<CodeBlock code={code} />title renders a header bar with the filename, and the copy button moves into
it.
<script lang="ts">
export let name: string;
</script>
<p>Hello, {name}!</p><CodeBlock code={code} title="app/routes/+page.svelte" />language on its own renders a non-interactive tag in the header. It also
stamps class="language-css" on the default code. That class is the hook a
highlighter running in the browser (Prism, highlight.js) decorates after mount. Set both title and language to show them together.
.hz-code-block {
border-radius: var(--hz-radius-md, 0.5rem);
}<CodeBlock code={code} language="css" />export function scoreToPar(strokes: number, par: number): number {
return strokes - par;
}<CodeBlock code={code} title="scoring.ts" language="ts" />lineNumbers adds a decorative gutter. It is aria-hidden and left
out of copy and selection, so a mouse drag and a screen reader both get clean source.
export interface Round {
course: string;
holes: number;
strokes: number;
}<CodeBlock code={code} lineNumbers />collapsible clamps a listing longer than collapsedLines (default 16) behind a Show-more/less toggle. The code below is the reference CSS reset the
library ships, not filler lines.
/**
* @hyzer-labs/ui reset (optional)
*
* Structural adaptation of Josh Comeau's custom CSS reset:
* https://www.joshwcomeau.com/css/custom-css-reset/
*
* Deliberately colorless and font-less — tokens.css and the reference theme
* (or your own CSS) own those. Usable with or without the theme.
*
* Rules live in the `hz-reset` cascade layer, pinned before `hz-theme`, so
* the reference theme and any unlayered consumer CSS always win ties.
* Import this before other stylesheets.
*/
@layer hz-reset, hz-theme;
@layer hz-reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
/* Stop mobile Safari (and Firefox on Android) inflating text on its own.
* The engines do this to rescue non-responsive pages, and on a responsive
* layout it just makes some text bigger than you asked for — most visibly
* in landscape. `none` does NOT disable user zoom: the old WebKit bug that
* made `100%` the safer value has been fixed.
* https://kilianvalkhof.com/2022/css-html/your-css-reset-needs-text-size-adjust-probably/ */
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
@media (prefers-reduced-motion: no-preference) {
html {
interpolate-size: allow-keywords;
}
}
body {
line-height: var(--hz-line-height-base, 1.5);
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
/* The library's own inline icons stay inline — block svgs would wrap
* them onto their own line mid-text (flex/grid parents blockify anyway). */
svg.hz-icon {
display: inline-block;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
p {
text-wrap: pretty;
}
h1,
h2,
h3,
h4,
h5,
h6 {
text-wrap: balance;
}
/* An anchored element otherwise lands flush against the top of the
* viewport, or underneath a sticky header. `ex` scales with the font, so
* the offset stays proportional to the text rather than a fixed guess. */
:target {
scroll-margin-block: 5ex;
}
/* Descenders stay legible where an underline would cut through them.
* Scoped to unstyled links so a themed link keeps whatever its theme
* decides — and no color is set here, which is the reset's whole posture. */
a:not([class]) {
text-decoration-skip-ink: auto;
}
}<CodeBlock code={code} language="css" collapsible />The library ships no highlighter
language class hook
for a highlighter that runs in the browser (Prism, highlight.js), or the children escape hatch to pass in a build-time highlighter's own HTML (Shiki,
shown below).Prism: highlighting in the browser
PrismCodeBlock is a small wrapper written for these docs. It renders a
plain <CodeBlock language> and runs Prism over its pre code in an $effect after mount, using the hook language stamps.
Prism's own token theme is scoped under .hz-docs-prism, so it cannot bleed
onto any other block on this site.
interface Round {
course: string;
holes: number;
strokes: number;
}
function scoreToPar(round: Round, par: number): number {
return round.strokes - par;
}<PrismCodeBlock code={source} language="ts" />PrismCodeBlock.svelte, verbatim
<script lang="ts">
/**
* the live "Prism, client-autoloader" demo.
*
* The ONLY docs importer of `prismjs` (highlighter-isolation.spec.ts pins
* it) — a self-contained wrapper the code-block docs page
* uses for exactly one live block. Renders a plain `<CodeBlock
* language>`, whose `language-<x>` class is the hook CodeBlock ships
* for precisely this: a client-side autoloader decorates it after mount.
*
* `prismjs` sets its shared state on the real DOM `window` (prism-core.js)
* rather than a module-scoped export, so importing it eagerly at module
* scope would throw during SSR/prerender (adapter-static prerenders every
* route). The dynamic import inside `$effect` is what keeps this
* client-only — `$effect` never runs during SSR, so no highlighter code
* ships to, or runs on, the server.
*/
import { CodeBlock } from '$lib';
interface Props {
code: string;
language: string;
}
let { code, language }: Props = $props();
let el = $state<HTMLElement>();
$effect(() => {
if (!el) return;
let cancelled = false;
(async () => {
const Prism = (await import('prismjs')).default;
await import('prismjs/components/prism-clike.js');
await import('prismjs/components/prism-javascript.js');
await import('prismjs/components/prism-typescript.js');
if (cancelled || !el) return;
Prism.highlightAllUnder(el);
})();
return () => {
cancelled = true;
};
});
</script>
<div bind:this={el} class="hz-docs-prism">
<CodeBlock {code} {language} />
</div>
<style>
/*
* A hand-scoped port of Prism's own bundled "Tomorrow Night" theme
* (prismjs/themes/prism-tomorrow.css) — same token colours, rescoped
* under .hz-docs-prism so they cannot bleed onto any other code block on
* the site. Prism's stock theme file targets bare
* `.token.*` selectors with no scoping mechanism of its own, so it can't
* be imported as-is here. The dark surface these colours were designed
* against is set locally via the documented --hz-code-block-bg hook
* — no fork of the component or its theme required.
*/
.hz-docs-prism :global(.hz-code-block) {
--hz-code-block-bg: #2d2d2d;
}
.hz-docs-prism :global(.hz-code-block-clip code) {
color: #ccc;
}
.hz-docs-prism :global(.token.comment),
.hz-docs-prism :global(.token.prolog),
.hz-docs-prism :global(.token.doctype),
.hz-docs-prism :global(.token.cdata) {
color: #999;
}
.hz-docs-prism :global(.token.punctuation) {
color: #ccc;
}
.hz-docs-prism :global(.token.tag),
.hz-docs-prism :global(.token.attr-name),
.hz-docs-prism :global(.token.namespace),
.hz-docs-prism :global(.token.deleted) {
color: #e2777a;
}
.hz-docs-prism :global(.token.function-name) {
color: #6196cc;
}
.hz-docs-prism :global(.token.boolean),
.hz-docs-prism :global(.token.number),
.hz-docs-prism :global(.token.function) {
color: #f08d49;
}
.hz-docs-prism :global(.token.property),
.hz-docs-prism :global(.token.class-name),
.hz-docs-prism :global(.token.constant),
.hz-docs-prism :global(.token.symbol) {
color: #f8c555;
}
.hz-docs-prism :global(.token.selector),
.hz-docs-prism :global(.token.important),
.hz-docs-prism :global(.token.atrule),
.hz-docs-prism :global(.token.keyword),
.hz-docs-prism :global(.token.builtin) {
color: #cc99cd;
}
.hz-docs-prism :global(.token.string),
.hz-docs-prism :global(.token.char),
.hz-docs-prism :global(.token.attr-value),
.hz-docs-prism :global(.token.regex),
.hz-docs-prism :global(.token.variable) {
color: #7ec699;
}
.hz-docs-prism :global(.token.operator),
.hz-docs-prism :global(.token.entity),
.hz-docs-prism :global(.token.url) {
color: #67cdcc;
}
.hz-docs-prism :global(.token.important),
.hz-docs-prism :global(.token.bold) {
font-weight: bold;
}
.hz-docs-prism :global(.token.italic) {
font-style: italic;
}
.hz-docs-prism :global(.token.inserted) {
color: green;
}
</style>
Shiki: highlighting at build time
This block's HTML is produced once, at build, by this page's own +page.server.ts: a server load that calls Shiki's codeToHtml and returns the highlighted string. The page passes that string
through the children escape hatch. Shiki's palette comes from its own
inline .shiki styles. Wherever data-highlighted is present, the reference
theme clears CodeBlock's own surface fill and lets that palette show through. The block still
supplies the frame: border, radius, header, copy button, and collapse. No highlighter JavaScript
reaches the browser.
<script lang="ts">
let count = $state(0);
</script>
<button onclick={() => count++}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
<CodeBlock code={source}>{@html data.shikiHtml}</CodeBlock>+page.server.ts, verbatim
import { codeToHtml } from 'shiki';
import { shikiDemoSource } from '../../../../docs/samples/code-block-demo.js';
import type { PageServerLoad } from './$types.js';
export const load: PageServerLoad = async () => {
const shikiHtml = await codeToHtml(shikiDemoSource, {
lang: 'svelte',
theme: 'github-dark'
});
return { shikiHtml };
};highlight.js works the same way as Prism: it decorates the same language-<x> class after mount. These docs ship no sample or dependency
for it.
Props
| Name | Type | Default | Note |
|---|---|---|---|
code | string | — | Required. The source text. Copy, the line count, and the gutter always read from this, never from `children` or highlighter-injected markup. |
children | Snippet | — | Escape hatch for pre-highlighted content, usually a build-time highlighter's own `<pre>` via `{@html}` (Shiki, for example). Replaces the default `<pre><code>` entirely; `code` is still required. |
title | string | — | A filename or label. Renders the header bar, together with `language`. |
language | string | — | Stamps `class="language-<language>"` on the default code (the hook browser-side highlighters look for) and shows a visible, non-interactive tag in the header. |
lineNumbers | boolean | false | Opt-in decorative line-number gutter. Never part of copy or selection. |
copyable | boolean | true | Whether the built-in copy button renders. |
collapsible | boolean | false | Clamp tall listings behind a Show-more/less toggle. |
collapsedLines | number | 16 | Rows shown while collapsed. The toggle appears only when the listing is longer than this. |
class | string | — | Merged after the hz-code-block class. |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-code-block
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-language | <string> — present when language is set | Mirrors the language prop; the default code also carries language-<value> for client autoloaders, and the header shows a matching tag. |
data-has-title | present when title is set | Marks the titled form. Independent of data-language — the header can render from either. |
data-highlighted | present when the children escape hatch is used | Marks a block whose code node is consumer-supplied pre-highlighted markup. Target it to drop the default code-surface fill and let a build-time highlighter's own background sit cleanly. |
data-collapsible | present when the listing exceeds collapsedLines | Reflects effective behaviour, not the raw prop — a short listing never gets the toggle, so this is absent for it. |
data-line-numbers | present when lineNumbers is on | Marks the gutter form. |
Custom properties
| Hook | Values | Styles |
|---|---|---|
--hz-code-block-bg | <color> — default var(--hz-color-surface-muted) | The code surface fill; the collapse fade fades into it. Suppressed under data-highlighted so a build-time highlighter's own background shows. |
--hz-code-block-padding | <length> — default 1rem | Padding inside the code region (pre) — reaches a consumer-supplied pre too. |
--hz-code-block-fade-height | <length> — default 3rem | Height of the collapse fade over the clamped edge. |
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-code-block-header | child element | The header bar; present when title or language is set. |
.hz-code-block-title | child element | The filename/label text. |
.hz-code-block-lang | child element | The non-interactive language tag; present only when language is set. |
.hz-code-block-clip | child element | The code region — the collapsible viewport, focusable when it renders the default code node. |
.hz-code-block-gutter | child element | The aria-hidden, non-selectable line-number column; present only with lineNumbers. |
.hz-code-block-copy | on a Button | Rides through Button’s class prop, so it also carries .hz-button and its data-attrs. |
.hz-code-block-expand | on a Button | The Show-more/less toggle; also a .hz-button. |
code.language-<language> | on the default inner code | The class a client autoloader targets; present only when language is set and the children escape hatch is not used. Not an hz-owned class. |
Accessibility
The copy button is a real labeled <button>. Its "Copied" state lasts about 2 seconds and is announced once through a polite live region. If the clipboard is denied or unavailable, nothing happens and nothing is announced, so you never get a false "copied" (WCAG 4.1.3).
In its default rendering the code region is a focusable, named role="group", so you can scroll an overflowing listing with the arrow keys (WCAG 2.1.1). When children supplies pre-highlighted markup, the region gives up its own tab stop to your focusable <pre> (Shiki's carries tabindex="0") rather than creating a second one.
The language tag is plain visible text with no interactive role. It is never the region's accessible name, so nothing is announced twice. The line-number gutter is aria-hidden and left out of selection, so a screen-reader user and a mouse copy both get clean source.
If you supply a highlighter's markup through children, its token colors (a Shiki theme, say) are yours to check for contrast. CodeBlock adds no color to that markup.
References: APG: Disclosure Pattern · MDN: Clipboard API