ValyroUI Русская версия

Getting started: SvelteKit

Flash-free SSR: hooks.server.ts injects the theme from a cookie into the server-rendered <html>; styles are imported in the root layout

1. Create the app

Tested with SvelteKit 3.0 and Svelte 5.57. In SvelteKit 3, the Kit config (and the adapter) lives in vite.config.ts, and $lib is replaced by #lib subpath imports.

npx sv create my-app --template minimal --types ts --no-add-ons --install npm
cd my-app

2. Install the package

You only need the package's CSS: valyro-ui/styles.css (plus the theme file if your theme isn't one of the two built-in ones). Fonts are separate files: the vercel theme (the ThemeProvider default) uses Geist, so import valyro-ui/fonts/geist.css and valyro-ui/fonts/geist-mono.css next to the styles, or text falls back to the system font; glass uses system fonts and needs none. npm installs react and react-dom into node_modules as peer dependencies, but they stay out of your bundle unless you import them. Until the package is published, install the tarball: npm install ../valyro-ui-0.1.0.tgz.

npm install valyro-ui

3. Add the styles

<script lang="ts">
	import 'valyro-ui/styles.css';
	import favicon from '#lib/assets/favicon.svg';
	import type { LayoutProps } from './$types';

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

<svelte:head>
	<link rel="icon" href={favicon} />
</svelte:head>

{@render children()}

4. Flash-free theme

data-theme (vercel, glass or a theme loaded from its own file) and data-mode (light or dark) on <html> turn the theme on: the --ui-* tokens and the page background depend on them. The document template holds placeholders instead of values; the server hook replaces them.

<!doctype html>
<html lang="en" data-theme="%ui.theme%" data-mode="%ui.mode%">
	<head>
		<meta charset="utf-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1" />
		<meta name="text-scale" content="scale" />
		%sveltekit.head%
	</head>
	<body data-sveltekit-preload-data="hover">
		<div style="display: contents">%sveltekit.body%</div>
	</body>
</html>

5. Server hook

The hook reads the cookies, lets through only known values and writes them into the HTML before it's sent. In SvelteKit 3, import the Handle type from @sveltejs/kit/hooks.

import type { Handle } from '@sveltejs/kit/hooks';

const THEMES = ['glass', 'vercel'] as const;
const MODES = ['light', 'dark'] as const;

// Cookie values are user input: only known values reach the HTML.
function pick<T extends string>(value: string | undefined, allowed: readonly T[], fallback: T): T {
	return allowed.includes(value as T) ? (value as T) : fallback;
}

export const handle: Handle = async ({ event, resolve }) => {
	const theme = pick(event.cookies.get('ui-theme'), THEMES, 'glass');
	const mode = pick(event.cookies.get('ui-mode'), MODES, 'light');

	return resolve(event, {
		transformPageChunk: ({ html }) => html.replace('%ui.theme%', theme).replace('%ui.mode%', mode)
	});
};

6. Switcher and markup

The buttons flip the attribute on <html> and save the choice in a cookie for the next server render. The markup is exactly what the React components render (Button, Badge, TextInput, Alert, Card). Only visual components work without React (see the list in the HTML + CSS guide); for any of them, the “Usage” example on the component's page shows the markup when this framework is selected.

<script lang="ts">
	// The server already wrote data-theme/data-mode on <html> (hooks.server.ts);
	// the buttons flip the attribute and persist the choice in a cookie for the next SSR.
	function toggle(key: 'theme' | 'mode', a: string, b: string) {
		const next = document.documentElement.dataset[key] === a ? b : a;
		document.documentElement.dataset[key] = next;
		document.cookie = `ui-${key}=${next}; path=/; max-age=31536000; samesite=lax`;
	}
</script>

<main style="display: grid; justify-items: start; gap: 16px; max-width: 560px; padding: 32px">
	<div style="display: flex; gap: 8px">
		<button class="ui-button" data-variant="secondary" data-size="md" type="button" onclick={() => toggle('theme', 'glass', 'vercel')}>
			<span class="ui-button__label">Toggle theme</span>
		</button>
		<button class="ui-button" data-variant="secondary" data-size="md" type="button" onclick={() => toggle('mode', 'light', 'dark')}>
			<span class="ui-button__label">Toggle mode</span>
		</button>
	</div>

  <button class="ui-button" data-variant="primary" data-size="md" type="button"><span class="ui-button__label">Deploy</span></button>
  <span class="ui-badge" data-color="green" data-variant="subtle" data-size="md"><span class="ui-badge__label">Ready</span></span>
  <div class="ui-field ui-text-input" data-size="md"><input aria-label="Email" placeholder="[email protected]" type="text" class="ui-field__input ui-text-input__input" /></div>
  <div role="status" class="ui-alert" data-variant="info">
    <span class="ui-alert__icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false" class="ui-icon"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg></span>
    <div class="ui-alert__content"><div class="ui-alert__title">Heads up</div><div class="ui-alert__description">Static markup, no React at runtime.</div></div>
  </div>
  <div class="ui-card ui-panel" data-variant="default" data-padding="md">
    <div class="ui-card__header"><h3 class="ui-card__title">Project</h3><p class="ui-card__description">Production deployment</p></div>
    <div class="ui-card__content">Body</div>
    <div class="ui-card__footer"><button class="ui-button" data-variant="secondary" data-size="md" type="button"><span class="ui-button__label">Open</span></button></div>
  </div>
</main>

7. Build and run

With the default adapter (adapter-auto), use npm run preview. For your own Node server, install @sveltejs/adapter-node and change the adapter import in vite.config.ts to import adapter from '@sveltejs/adapter-node';.

npm run check
npm run build
npm run preview

# own Node server
npm i -D @sveltejs/adapter-node
npm run build
node build

8. Interactive components as a React island

Select, Dialog and other stateful components work only in React. A Svelte action mounts them with createRoot in the browser only, so React never renders during SSR. The bundle of the page with the island grows by React 19 (≈ +80 kB gzip); other pages stay the same.

npm install react react-dom
npm install -D @types/react @types/react-dom

9. Island action

ThemeProvider writes the theme to <html>, so pass it the current values. Its children prop is required: pass it in the props object, not as the third argument to createElement.

import { createElement, type ComponentType } from 'react';
import { createRoot } from 'react-dom/client';
import { ThemeProvider, type ThemeMode, type ThemeName } from 'valyro-ui';

export interface IslandParams<P extends object> {
	component: ComponentType<P>;
	props: P;
	theme: ThemeName;
	mode: ThemeMode;
}

// Svelte action: actions run only in the browser, so React never renders during SSR.
export function reactIsland<P extends object>(node: HTMLElement, params: IslandParams<P>) {
	const root = createRoot(node);
	const render = ({ component, props, theme, mode }: IslandParams<P>) =>
		root.render(
			createElement(ThemeProvider, { theme, mode, locale: 'en-US', children: createElement(component, props) })
		);
	render(params);
	return { update: render, destroy: () => root.unmount() };
}

10. Theme for the island

The island needs the theme and mode before it mounts. Store them in locals: in handle, add event.locals.theme = theme; and event.locals.mode = mode; before return, and declare the type in app.d.ts.

// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
	namespace App {
		// interface Error {}
		interface Locals {
			theme: 'glass' | 'vercel';
			mode: 'light' | 'dark';
		}
		// interface PageData {}
		// interface PageState {}
		// interface Platform {}
	}
}

export {};

11. Page with an island

import type { PageServerLoad } from './$types';

export const load: PageServerLoad = ({ locals }) => ({ theme: locals.theme, mode: locals.mode });

12. Island in the markup

Import #lib/react-island.ts with the extension: subpath imports don't add it, and without it svelte-check can't find the module.

<script lang="ts">
	import { Select } from 'valyro-ui';
	import { reactIsland } from '#lib/react-island.ts';
	import type { PageProps } from './$types';

	let { data }: PageProps = $props();
	let theme = $derived(data.theme);
	let mode = $derived(data.mode);

	function save(key: 'theme' | 'mode', value: string) {
		document.documentElement.dataset[key] = value;
		document.cookie = `ui-${key}=${value}; path=/; max-age=31536000; samesite=lax`;
	}

	const options = [
		{ value: 'eu', label: 'Europe' },
		{ value: 'us', label: 'United States' }
	];
</script>

<main style="display: grid; gap: 16px; max-width: 640px; padding: 32px">
	<div style="display: flex; gap: 8px">
		<button class="ui-button" data-variant="secondary" data-size="md" type="button" onclick={() => save('theme', (theme = theme === 'glass' ? 'vercel' : 'glass'))}>
			<span class="ui-button__label">Toggle theme</span>
		</button>
		<button class="ui-button" data-variant="secondary" data-size="md" type="button" onclick={() => save('mode', (mode = mode === 'light' ? 'dark' : 'light'))}>
			<span class="ui-button__label">Toggle mode</span>
		</button>
	</div>

	<!-- React island: a ValyroUI Select rendered by React inside a Svelte page. -->
	<div use:reactIsland={{ component: Select, props: { 'aria-label': 'Region', defaultValue: 'eu', options }, theme, mode }}></div>
</main>

Other frameworks