Styling & strict CSP
Theme Cloudlet with CSS tokens and class names, render it into your own container, and run it under a strict Content Security Policy.
On this page
Cloudlet styles itself with one external stylesheet and a set of scoped CSS custom properties. This page lists the tokens you can override, how the theme and the writing direction are chosen, how to attach your own classes to each part, where the portal renders, and what a strict Content Security Policy needs. Every option here is a prop of the Toaster; the full list is in Toaster props.
The stylesheet
Import @cloudlet/toast/styles.css once, from your global CSS entry or root layout (see Installation). Its rules are scoped to .cloudlet-host, the element the Toaster renders, and carry their own reset, so your page styles do not leak in and Cloudlet's do not leak out.
Put your overrides in your own stylesheet, imported after Cloudlet's. The renderer drives the geometry of the pill and the Island itself: their width, height, position, transform and clip path follow the animation. Change the look through tokens and class names, not by overriding those properties.
Tokens
Every token is a custom property on .cloudlet-host. Typography and geometry do not depend on the theme. Text uses --cloudlet-font-family, system-ui, sans-serif by default, at --cloudlet-font-size, .875rem; descriptions and actions are a step smaller. Geometry:
| Token | Default | Description |
|---|---|---|
--cloudlet-pill-height | 44px | Minimum height of the collapsed pill. |
--cloudlet-pill-radius | 9999px | Corner radius of the pill and its tails. |
--cloudlet-pill-max-width | 22rem | The pill grows with its title up to this width. |
--cloudlet-island-radius | 24px | Corner radius of the open Island. |
--cloudlet-island-max-width | 26rem | Width of the open Island, within the viewport. |
--cloudlet-tail-offset | 4px | How far each tail peeks out from behind the one in front. |
--cloudlet-tail-scale | .9 | Scale of the first tail relative to the pill; the second is scaled again. |
--cloudlet-control-min-size | 44px | Touch target of buttons and links. |
--cloudlet-viewport-gutter | 16px | Gap to the viewport edge; see Offsets. |
--cloudlet-z-index | 1000 | Stacking order of the fixed host. |
The safe-area insets are tokens too: --cloudlet-safe-top, --cloudlet-safe-right, --cloudlet-safe-bottom and --cloudlet-safe-left default to the browser's env(safe-area-inset-*).
Surface colours change with the theme:
| Token | Light | Dark |
|---|---|---|
--cloudlet-background | #ffffff | #1c1c1e |
--cloudlet-foreground | #111113 | #f5f5f7 |
--cloudlet-muted | #6e6e76 | #a1a1aa |
--cloudlet-focus | #245cdb | #93b4ff |
--cloudlet-shadow | a soft two-layer shadow | a deeper single shadow |
--cloudlet-tail-background | the background | the foreground mixed into the background |
--cloudlet-tail-border | the border | the foreground at 18% |
--cloudlet-border and --cloudlet-action-background are derived from the foreground with color-mix(), so they follow it in both themes. The status colours --cloudlet-success, --cloudlet-error, --cloudlet-info, --cloudlet-warning and --cloudlet-loading, and the badge pair --cloudlet-badge and --cloudlet-badge-foreground, are the same in both themes.
Overriding tokens
Theme-independent tokens and the status colours can be set on .cloudlet-host:
.cloudlet-host {
--cloudlet-font-family: 'Inter', system-ui, sans-serif;
--cloudlet-pill-max-width: 24rem;
--cloudlet-island-radius: 20px;
--cloudlet-success: oklch(0.7 0.17 160);
}The dark surface colours are set by a more specific selector, so a plain .cloudlet-host rule changes the light theme only. To map Cloudlet onto your application's own colour tokens in both themes, target .cloudlet-host[data-theme] and keep the rule out of forced-colours mode, where Cloudlet switches to system colours:
/* Your --surface/--text/--ring are defined on :root and on your .dark root. */
@media (forced-colors: none) {
.cloudlet-host[data-theme] {
--cloudlet-background: var(--surface);
--cloudlet-foreground: var(--text);
--cloudlet-muted: var(--text-muted);
--cloudlet-focus: var(--ring);
}
}The tokens must reach the portal: define them on :root or on an ancestor of the container, not on a wrapper around your React tree, which does not contain the default portal on document.body. This is the approach of the shadcn registry item's toaster.css, which maps --popover, --popover-foreground, --muted-foreground, --border, --ring, --accent and --font-sans.
Theme
<Toaster theme> is system by default:
systemfollows the user'sprefers-color-scheme.lightanddarkforce a theme, whatever the operating system prefers.
If your application has its own theme switch, pass the theme it resolved, so that Cloudlet's surface and the native color-scheme of its controls agree with the page. The dark theme changes the background, foreground, muted text, focus ring, shadow and tails; the status and badge colours stay. The host carries the prop's value as data-theme, so system stays system on a dark screen, and the theme in effect as data-resolved-theme (light or dark), which follows the operating system while the theme is system:
.cloudlet-host[data-resolved-theme="dark"] .app-toast-action { font-weight: 500; }data-resolved-theme appears once the Toaster has mounted. The built-in dark surface does not depend on it: it uses the prefers-color-scheme media query and works before any script runs.
In forced-colours mode (Windows High Contrast), every theme uses system colours: Canvas, CanvasText and Highlight, with visible borders on controls.
Direction
<Toaster dir> is ltr or rtl. When it is omitted, the Toaster follows dir="rtl" on the <html> element. In right-to-left mode, text aligns to the start, the badge and the slider are mirrored, and the arrow keys move between notifications in the reading direction. Use the RTL toggle in the Playground to see it.
Class names
classNames adds your classes to the parts of the renderer. They are added next to Cloudlet's own cloudlet-* classes and never replace them, so the stylesheet keeps working:
import { CloudletProvider, createCloudlet, Toaster } from '@cloudlet/toast';
import './notifications.css';
const cloudlet = createCloudlet();
export function Notifications() {
return (
<CloudletProvider controller={cloudlet}>
<Toaster
classNames={{
island: 'app-island',
title: 'app-toast-title',
action: 'app-toast-action',
}}
/>
</CloudletProvider>
);
}| Part | Element |
|---|---|
host | The fixed host: the region that holds the stack and the Island. |
stack | The stack: the pill, its tails and the badge. |
pill | The button of the collapsed stack. |
tail | Each of the two tails behind the pill. |
badge | The count of notifications in the stack. |
island | The shared shell of the pill and the Island. |
header | The row with the icon, the title and the close button. |
icon | The status icon and its progress ring. |
title | The title of the current notification. |
close | The close button of the Island. |
body | The scrollable area under the header. |
description | The description text or rich content. |
actions | The row of actions, retry and cancel. |
action | Each button or link in that row. |
indicator | The page dots or counter under the body. |
overflow | The missed-notifications line and its button. |
Cloudlet's own rules use two classes, such as .cloudlet-host .cloudlet-action, so a selector with a single class of yours loses to them on the properties they set. Write rules at least as specific, or style through the tokens:
.cloudlet-host .app-toast-title { letter-spacing: -0.01em; }
.cloudlet-host .app-toast-action { font-weight: 600; }Utility classes that live in a cascade layer, as Tailwind's do, lose to Cloudlet's unlayered stylesheet for every property it sets. They still apply to properties Cloudlet leaves alone.
To let utilities win, import the stylesheet into a layer of your own, ordered before utilities, instead of importing it from JavaScript:
@layer theme, base, cloudlet, components, utilities;
@import "tailwindcss";
@import "@cloudlet/toast/styles.css" layer(cloudlet);The stylesheet is unlayered on purpose: then a page's own resets, such as button { all: unset }, cannot break the notifications. Inside a layer, every unlayered rule of the page wins over Cloudlet, so check your global styles after the switch.
The host also exposes its state as data attributes you can select on: data-state (stack or island), data-island-reason, data-position, data-edge (top or bottom), data-theme, data-resolved-theme, data-reduced-motion and data-dragging. The current notification carries data-kind and data-current.
Portal container
By default the Toaster renders into document.body through a portal. container renders it into an element of yours: pass the element, or a function that returns it. Cloudlet reads it after mount, and it must be:
- a connected HTML element in the document the Toaster mounts in;
- outside Shadow DOM.
The container is fixed for the life of the attachment. To move the Toaster into another element, remount it, for example by changing its key. An unusable container is reported to onDiagnostic and no host is rendered.
A modal <dialog> puts the rest of the page behind it, so notifications that belong to the dialog should render inside it. One Toaster is active per controller, so such a dialog gets its own controller:
import { useRef, type ReactNode } from 'react';
import { CloudletProvider, createCloudlet, Toaster } from '@cloudlet/toast';
// The dialog's own notifications: the page Toaster keeps its controller.
const dialogCloudlet = createCloudlet();
export function EditorDialog({ children }: { children: ReactNode }) {
const dialog = useRef<HTMLDialogElement>(null);
return (
<dialog ref={dialog}>
<CloudletProvider controller={dialogCloudlet}>
{children}
<Toaster container={() => dialog.current} />
</CloudletProvider>
</dialog>
);
}You are responsible for the container being visible and suitable. The host is position: fixed; an ancestor with a transform, filter or overflow: hidden can clip it or become its containing block, and no z-index overcomes that.
Strict CSP
Cloudlet works under a policy that forbids inline style attributes. It writes the animated geometry through CSSOM rules in its own <style> blocks, and uses no style attributes, no cssText and no eval. Under a nonce-based policy, pass the current request's nonce to every Toaster so the browser accepts those blocks:
import { CloudletProvider, createCloudlet, Toaster } from '@cloudlet/toast';
const cloudlet = createCloudlet();
export function Notifications({ nonce }: { nonce: string }) {
return (
<CloudletProvider controller={cloudlet}>
<Toaster nonce={nonce} />
</CloudletProvider>
);
}A representative production policy:
default-src 'none'; script-src 'self' 'nonce-REQUEST_NONCE';
style-src 'self' 'nonce-REQUEST_NONCE'; style-src-attr 'none';
img-src 'self' data:; font-src 'self'; connect-src 'self';
base-uri 'none'; object-src 'none'; frame-ancestors 'none'- Keep
styles.cssexternal: it is served from'self'like the rest of your CSS. - Use a fresh, unpredictable nonce for every response, and never copy a fixed nonce into production.
- Your application and framework must put the nonce on their own inline scripts and style blocks too. SSR & Next.js shows how to read it in the App Router.
- A nonce does not permit style attributes. Under
style-src-attr 'none', rich descriptions and icons must not use Reactstyleprops or untrusted markup.
Cloudlet's integration tests run production Vite and Next.js builds under this policy and watch for securitypolicyviolation while they drive the pill, the Island, slides, swipes and auto-opening.
Motion is not a styling API
<Toaster motion> exists for debugging. timeScale slows every transition (0.5 is twice as slow), and overrides replaces the built-in springs and durations live, for tuning in a laboratory. The values may change between releases: do not ship either to production, and do not use them to restyle the stack. Users who prefer reduced motion get crossfades instead of movement automatically; see Accessibility.
Next steps
- Try it in the Playground: the Top, Bottom and RTL toggles and the site's own light and dark themes.
- Toaster props: every prop with its type and default.
- SSR & Next.js: where the controller lives and how the nonce reaches the Toaster.