@k8ordo/color-scheme

Storage

The visitor’s preference is stored in localStorage as an ordinary @k8ordo/state local state. This package holds one definition and nothing more: the key, the row’s shape and the sync between tabs are @k8ordo/state’s. This page covers what the row holds, reading it without the hook, how tabs agree, and keeping the application’s other preferences beside it.

One definition: colorSchemeState

The definition of where the preference is kept is exported as colorSchemeState, and this is all of it.

ts
import { defineLocalState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const colorSchemeState = defineLocalState(
  'color-scheme',
  z.object({ preference: z.optional(z.enum(['light', 'dark'])) }),
);

The key is color-scheme, so the localStorage key is k8ordo-state:color-scheme (colorSchemeState.storageKey). preference is optional, and its absence is what “nothing chosen” means.

What the row holds

A row is written only when setPreference is called; the provider’s default is never stored.

WhenThe stored row
Never choseno row (getItem returns null)
setPreference('dark'){"preference":"dark"}
setPreference('light'){"preference":"light"}
setPreference('system'){}

Going back to 'system' does not remove the row: {} remains, with no preference in it. Anything that reads preference treats it the same as no row.

Reading it elsewhere

To read the row without the hook, call useAppState(colorSchemeState) from any client component. @k8ordo/state has no provider and keeps one store per key, so it reads what <ColorSchemeProvider> reads.

tsx
// src/components/stored-preference.tsx
'use client';

import { colorSchemeState } from '@k8ordo/color-scheme';
import { useAppState } from '@k8ordo/state';

export function StoredPreference() {
  const [{ preference }] = useAppState(colorSchemeState);

  return <output>{preference ?? 'system'}</output>;
}

What comes back is the stored preference ('light' | 'dark' | undefined), not the resolved scheme; for what is on screen, use useColorScheme(). On the server and in the hydration render it is the nothing-stored value, undefined.

An inline script of your own that needs the row before the first paint has colorSchemeState.inlineRead(): it returns a JavaScript expression that evaluates to the stored object, or to null when there is none it can read. The schema does not run there, so check each field you use. @k8ordo/state: Reading before hydration

Tabs agree

@k8ordo/state’s local state listens for storage events on its key. A choice made in another tab, or localStorage cleared there, reaches this tab’s provider, and the class changes in place.

A storage event never reaches the tab that wrote. Writing localStorage.setItem('k8ordo-state:color-scheme', …) by hand in the same tab goes unnoticed by the provider until a reload. Change it through setPreference.

Other preferences beside it

When the application has other display preferences, define a defineLocalState of its own under another key. This site’s writing-mode preference is one, stored in a row apart from the colour scheme. Keep the definition in a module without 'use client': exported from a 'use client' file, it reaches a Server Component as a client reference, not as the definition.

ts
// src/theme/state.ts
import { defineLocalState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const writingModeState = defineLocalState(
  'writing-mode',
  z.object({ mode: z.optional(z.enum(['horizontal', 'vertical'])) }),
);

Do not define another defineLocalState('color-scheme', …) in the application: @k8ordo/state shares stores by key, so the two definitions would fight over one row and one store.

Where @k8ordo/state keeps state