Skip to content
On this page Full-page islands

Layout

ThemeIsland

A block or a whole page that uses another preset and scheme than the app, with the same first paint on the server. Mirrors them to html while mounted.

<script>
  import { ThemeIsland } from "@codeatlasdev/ui";
</script>

<ThemeIsland preset="paper" scheme="light" document>
  …the page…
</ThemeIsland>

ThemeIsland renders a wrapper with data-preset and data-theme-scope, so everything inside re-resolves its tokens (--ca-surface-base, text, brand, surface fills) and color-scheme. Without document that is all it does: a card or a section in another preset or scheme.

#780

Scoped block

A light ocean island inside the current page.

Ink on cream

This block is light even when the app is dark.

Reference #780 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Button, ThemeIsland } from '@codeatlasdev/ui';
</script>

<!-- A light island inside whatever scheme the page uses. -->
<ThemeIsland preset="ocean" scheme="light" class="island">
  <div class="island-body">
    <h3>Ink on cream</h3>
    <p>This block is light even when the app is dark.</p>
    <Button variant="filled" size="sm">Get started</Button>
  </div>
</ThemeIsland>

<style>
  :global(.island) {
    min-height: 0;
    border-radius: var(--ca-radius-lg);
    overflow: hidden;
  }
  .island-body {
    display: grid;
    gap: var(--ca-space-3);
    padding: var(--ca-space-6);
    justify-items: start;
  }
</style>

Full-page islands

html and body sit outside the wrapper, so on a full-page island they keep painting the app preset: visible in overscroll, behind the iOS toolbar and on pages that do not scroll. Add document to mirror data-preset, data-theme-scope and the dark class onto <html> while the island is mounted. color-scheme, the page canvas (--ca-surface-base) and the browser chrome follow. On unmount the app’s values come back, with no reload.

#781

Full page

Opens a rose, light page over the app and restores the app on close.

Reference #781 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Button, ThemeIsland } from '@codeatlasdev/ui';

  let open = $state(false);
</script>

<Button variant="outlined" size="sm" onclick={() => (open = true)}>Open full-page island</Button>

{#if open}
  <!-- `document` also paints <html> and <body> like the island and puts the app back on unmount. -->
  <div class="overlay">
    <ThemeIsland preset="rose" scheme="light" document>
      <div class="page">
        <h3>Full-page island</h3>
        <p>The overscroll area and the browser chrome match this page. Close it to restore the app theme.</p>
        <Button variant="filled" onclick={() => (open = false)}>Back to the app</Button>
      </div>
    </ThemeIsland>
  </div>
{/if}

<style>
  .overlay {
    position: fixed;
    inset: 0;
    z-index: var(--ca-z-modal);
    overflow: auto;
  }
  .page {
    display: grid;
    gap: var(--ca-space-4);
    justify-items: start;
    padding: var(--ca-space-10) var(--ca-page-px);
    max-width: 40rem;
  }
</style>
  • If the theme store changes the preset or scheme while the island is mounted (colorScheme.setPreset, toggle), the island stays and the app’s new choice is what comes back on unmount.
  • Islands may overlap during a client navigation (the next page mounts before the previous one is destroyed) and restore in any order.
  • Pass only scheme to change the scheme and keep the app preset.
  • An app write that equals the island’s own value (the store sets paper while the island is paper) is not distinguishable from the island, so it is not remembered.

First paint on the server

document runs in the browser, so the server response would still start with the app theme and flash. Declare the theme of the route so caThemeHandle writes it on <html> in the response itself, next to a data-ca-island marker that makes the init script leave it alone (the stored app preset is kept aside and restored on navigation).

// src/routes/(marketing)/+layout.server.ts
import { setRouteTheme } from "@codeatlasdev/ui/kit";

export const load = (event) => {
  setRouteTheme(event, { preset: "paper", scheme: "light" });
};

load shares event.locals with the hook, which is how the declaration reaches caThemeHandle. For a whole route group you can list it once in the hook instead, by event.route.id prefix (the longest prefix wins, and setRouteTheme overrides it):

// src/hooks.server.ts
export const handle = sequence(
  caThemeHandle(theme, {
    routes: { "/(marketing)": { preset: "paper", scheme: "light" } },
  })
);

The preset must exist in your theme (a brand preset or a built-in): an unknown id throws at request time instead of shipping an unthemed page. Render <ThemeIsland document> with the same values in the page, so client navigation applies and restores them too.

On Astro, put the same attributes on <html> in the layout of that page (data-preset, data-theme-scope, data-ca-island, and class="dark" for a dark island). The <ClientRouter /> swap carries the app preset across island and non-island pages.

Charts inside an island

Charts are canvas, so they cannot use CSS variables: they read their colors once with getComputedStyle. A chart inside <ThemeIsland> reads the island’s tokens, so it paints the island’s brand, text and surface, not the page’s. The island provides that scope to every chart below it.

For a scope you build yourself (an element with data-preset or data-theme-scope), wrap the content in ChartScope from @codeatlasdev/ui/charts; it reads tokens from the nearest scoped ancestor, and from <html> when there is none.

<script>
  import { ChartScope, LineChart } from "@codeatlasdev/ui/charts";
</script>

<div data-preset="violet" data-theme-scope="light">
  <ChartScope><LineChart series={series} categories={months} /></ChartScope>
</div>

Nested presets and data-theme-scope

Presets and scopes can nest freely; the nearest one wins:

Markup Result
data-preset="a" inside html.dark preset a, dark
data-preset="a" data-theme-scope="light" on one element preset a, light
data-theme-scope="light" inside a preset island the island’s preset, light
data-preset="b" inside data-theme-scope="light" preset b, light
data-theme-scope inside a preset island nested in preset a the nearest preset, the scope’s scheme

Each preset ships its tokens once per scheme under a fixed list of selectors ([data-preset="x"], .dark [data-preset="x"], a scope inside it, a scope around it, …) and a scope inside a nested island of another preset is excluded from the outer one, so file order never decides. Three or more alternating scope levels around one preset, or one preset at two nesting levels with another between them, are not supported. You do not write these selectors: use ThemeIsland and read tokens with var(--ca-*).

Props

PropTypeDefaultDescription
scheme'light' | 'dark'–Scheme of the island, whatever the app uses. Required.
presetstring–`data-preset` of the island (built-in or brand preset). Omit to keep the page's preset.
documentbooleanfalseAlso apply preset, scheme and the `dark` class to `<html>` while mounted, and restore the app's on unmount.
classstring''Extra class names on the wrapper.
childrenSnippet–The island content.