Skip to content

Web

@nano_kit/platform-web provides small reactive wrappers around browser APIs. The package is built on top of @nano_kit/store, so every helper returns a signal that can be used with effect, computed, framework integrations, query settings, or your own store utilities.

Instead of wiring browser event listeners by hand, use these helpers when you want Web API state to participate in Nano Kit reactivity.

Install the package using your favorite package manager:

pnpm add @nano_kit/store @nano_kit/platform-web
import {
BooleanCodec,
effect
} from '@nano_kit/store'
import {
$networkOnline,
$pageVisible,
localStored,
mediaQuery
} from '@nano_kit/platform-web'
const $dark = localStored('dark', false, BooleanCodec)
const $wide = mediaQuery('(min-width: 768px)', false)
const stop = effect(() => {
console.log({
dark: $dark(),
online: $networkOnline(),
pageVisible: $pageVisible(),
wide: $wide()
})
})
$dark(true)
stop()

localStored and sessionStored create writable signals backed by localStorage and sessionStorage.

import { BooleanCodec, debounce } from '@nano_kit/store'
import { localStored, sessionStored } from '@nano_kit/platform-web'
const $dark = localStored('dark', false, BooleanCodec)
const $draft = sessionStored<string | null>('draft', '', debounce(300))
$dark(true)
$draft('Hello')
$draft(null) /* Removes sessionStorage.draft */

syncedLocalStored and syncedSessionStored also listen for storage events, so the signal can react to changes from other browsing contexts.

import { effect } from '@nano_kit/store'
import { syncedLocalStored } from '@nano_kit/platform-web'
const $language = syncedLocalStored('language', 'en')
const stop = effect(() => {
document.documentElement.lang = $language()
})

All storage helpers support the same optional arguments as stored: default values, codecs, and setter rate limiters.

Assigning null or undefined removes the underlying localStorage or sessionStorage entry. If the signal has a default value, the signal falls back to that default after deletion.

mediaQuery wraps window.matchMedia(...).

import { mediaQuery } from '@nano_kit/platform-web'
const $wide = mediaQuery('(min-width: 768px)', false)
const $reducedMotion = mediaQuery('(prefers-reduced-motion: reduce)', false)

The optional second argument is the fallback value used when window is not available.

The package exports shared singleton signals for common browser state.

import {
$devicePixelRatio,
$fullscreen,
$innerHeight,
$innerWidth,
$networkOnline,
$outerHeight,
$outerWidth,
$pageVisible,
$screenLeft,
$screenOrientation,
$screenTop,
$scrollX,
$scrollY
} from '@nano_kit/platform-web'

Window size and position:

  • $innerWidth
  • $innerHeight
  • $outerWidth
  • $outerHeight
  • $scrollX
  • $scrollY
  • $screenLeft
  • $screenTop
  • $devicePixelRatio

Page and device state:

  • $networkOnline
  • $pageVisible
  • $screenOrientation
  • $fullscreen

Numeric window values use NaN when the browser value is not available. Boolean values use browser-friendly fallbacks: $networkOnline and $pageVisible start as true, and $fullscreen starts as false.

permission creates a writable signal for the current Permissions API state. The value is 'granted', 'denied', 'prompt', Error, or undefined before the first result.

import { effect } from '@nano_kit/store'
import { permission } from '@nano_kit/platform-web'
const $geolocationPermission = permission('geolocation')
const stop = effect(() => {
const state = $geolocationPermission()
if (state === 'granted') {
console.log('Geolocation is available')
}
})

The argument can be a permission name string or a full PermissionDescriptor.

$geolocation is backed by navigator.geolocation.watchPosition(...) and updates while the signal is active.

$staticGeolocation is backed by navigator.geolocation.getCurrentPosition(...) and reads a single snapshot when the signal starts.

import { effect } from '@nano_kit/store'
import { $geolocation } from '@nano_kit/platform-web'
const stopLive = effect(() => {
const result = $geolocation()
if (result && 'coords' in result) {
console.log(result.coords.latitude, result.coords.longitude)
}
})

Both signals can contain GeolocationPosition, GeolocationPositionError, or undefined.

cookieStored and syncedCookieStored adapt a CookieStore-compatible object to Nano Kit storage signals.

import { JsonCodec } from '@nano_kit/store'
import { cookieStored, syncedCookieStored } from '@nano_kit/platform-web'
const $theme = cookieStored<string | null>(cookieStore, {
name: 'theme',
maxAge: 60 * 60 * 24 * 30,
path: '/'
}, 'light')
const $profile = syncedCookieStored(cookieStore, 'profile', {}, JsonCodec)
$theme('dark')
$theme(null) /* Calls cookieStore.delete({ name: 'theme', path: '/' }) */

Use cookieStored when you only need reads and writes. Use syncedCookieStored when the cookie store supports change events and you want the signal to react to external cookie changes.

Assigning null or undefined deletes the underlying cookie through the provided CookieStore-compatible object. When cookieStored was created from options, deletion reuses the matching path, domain, and partitioned attributes so the browser removes the same cookie scope. If a default value was provided, the signal falls back to it after deletion.

For universal code, use the CookieStore$ injection token. By default it returns the browser cookieStore, so browser-only code does not need any setup.

import { inject } from '@nano_kit/store'
import {
CookieStore$,
cookieStored
} from '@nano_kit/platform-web'
export function Session$() {
const cookieStore = inject(CookieStore$)
const $session = cookieStored<string | null>(cookieStore, {
name: 'session',
path: '/',
sameSite: 'lax'
}, null)
return {
$session
}
}

During SSR and tests, provide CookieStore$ with VirtualCookieStore. It implements the CookieStore API from an incoming Cookie header and collects pending Set-Cookie headers from mutations.

import { InjectionContext, provide } from '@nano_kit/store'
import {
CookieStore$,
VirtualCookieStore
} from '@nano_kit/platform-web'
const cookieStore = new VirtualCookieStore(
'theme=dark; session=abc123',
'/dashboard'
)
const context = new InjectionContext([
provide(CookieStore$, cookieStore)
])
await cookieStore.set({
name: 'theme',
value: 'light',
path: '/',
sameSite: 'lax'
})
cookieStore.peek('theme') // 'light'
cookieStore.drainSetCookieHeaders()
// ['theme=light; Path=/; SameSite=Lax']

broadcasted creates a signal synchronized through BroadcastChannel. It is useful for transient cross-tab messages such as logout, refresh, or UI coordination events.

import { effect } from '@nano_kit/store'
import { broadcasted } from '@nano_kit/platform-web'
const $authEvent = broadcasted<'logout' | 'refresh'>('auth')
const stop = effect(() => {
if ($authEvent() === 'logout') {
console.log('Log out this tab')
}
})
$authEvent('logout')

broadcasted supports the same default value, codec, and setter rate limiter overloads as the storage helpers.

browserLocale picks the best locale from a browser-like language container. In the browser, pass navigator directly.

import { browserLocale } from '@nano_kit/platform-web'
const locale = browserLocale(navigator, ['en', 'ru'], 'en')

For universal code, use the Locales$ injection token. By default it returns navigator, so browser-only code does not need any setup.

import { inject } from '@nano_kit/store'
import {
Locales$,
browserLocale
} from '@nano_kit/platform-web'
export function Locale$() {
const locales = inject(Locales$)
return browserLocale(locales, ['en', 'ru'], 'en')
}

On the server, provide Locales$ with parseLocales(...) from the request Accept-Language header.

import { provide } from '@nano_kit/store'
import {
Locales$,
parseLocales
} from '@nano_kit/platform-web'
const context = [
provide(Locales$, parseLocales(request.headers.get('accept-language')))
]

parseLocales sorts languages by q quality and keeps declaration order when qualities are equal. Empty headers fall back to { language: 'en', languages: ['en'] }.

Use UserAgent$ when universal code needs the browser user agent string. In the browser it returns navigator.userAgent.

import { inject } from '@nano_kit/store'
import { UserAgent$ } from '@nano_kit/platform-web'
export function Device$() {
const userAgent = inject(UserAgent$)
return userAgent.includes('Mobile') ? 'mobile' : 'desktop'
}

On the server, provide UserAgent$ with the incoming User-Agent header.

import { provide } from '@nano_kit/store'
import { UserAgent$ } from '@nano_kit/platform-web'
const context = [
provide(UserAgent$, request.headers.get('user-agent') ?? '')
]