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:

TokenDefaultDescription
--cloudlet-pill-height44pxMinimum height of the collapsed pill.
--cloudlet-pill-radius9999pxCorner radius of the pill and its tails.
--cloudlet-pill-max-width22remThe pill grows with its title up to this width.
--cloudlet-island-radius24pxCorner radius of the open Island.
--cloudlet-island-max-width26remWidth of the open Island, within the viewport.
--cloudlet-tail-offset4pxHow far each tail peeks out from behind the one in front.
--cloudlet-tail-scale.9Scale of the first tail relative to the pill; the second is scaled again.
--cloudlet-control-min-size44pxTouch target of buttons and links.
--cloudlet-viewport-gutter16pxGap to the viewport edge; see Offsets.
--cloudlet-z-index1000Stacking 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:

TokenLightDark
--cloudlet-background#ffffff#1c1c1e
--cloudlet-foreground#111113#f5f5f7
--cloudlet-muted#6e6e76#a1a1aa
--cloudlet-focus#245cdb#93b4ff
--cloudlet-shadowa soft two-layer shadowa deeper single shadow
--cloudlet-tail-backgroundthe backgroundthe foreground mixed into the background
--cloudlet-tail-borderthe borderthe 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:

notifications.css
.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:

notifications.css
/* 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:

  • system follows the user's prefers-color-scheme.
  • light and dark force 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:

notifications.css
.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:

notifications.tsx
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>
  );
}
PartElement
hostThe fixed host: the region that holds the stack and the Island.
stackThe stack: the pill, its tails and the badge.
pillThe button of the collapsed stack.
tailEach of the two tails behind the pill.
badgeThe count of notifications in the stack.
islandThe shared shell of the pill and the Island.
headerThe row with the icon, the title and the close button.
iconThe status icon and its progress ring.
titleThe title of the current notification.
closeThe close button of the Island.
bodyThe scrollable area under the header.
descriptionThe description text or rich content.
actionsThe row of actions, retry and cancel.
actionEach button or link in that row.
indicatorThe page dots or counter under the body.
overflowThe 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:

notifications.css
.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:

globals.css
@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:

editor-dialog.tsx
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:

notifications.tsx
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.css external: 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 React style props 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