Skip to Content
BuildStyling & Assets

Styling & Assets

This guide covers styling with Tailwind 4, loading fonts, referencing uploaded assets, localizing copy, showing modals and toasts, and embedding third-party scripts. Full component signatures are in the components reference.

Tailwind 4

Style pages with Tailwind utility classes directly in JSX. styles.css is the Tailwind entry, and it starts as a single import:

styles.css
@import "tailwindcss";

Keep this file small. Utilities handle layout, spacing, color, and typography in the JSX; reserve styles.css for what utilities can’t express — web fonts, design tokens, and keyframes.

Design tokens with @theme

Define tokens in an @theme block and Tailwind generates the matching utilities:

styles.css
@import "tailwindcss"; @theme { --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --color-brand: #4f46e5; --color-brand-dark: #4338ca; }

--color-brand gives you bg-brand, text-brand, border-brand, and so on. --font-sans sets the default sans stack. This is the right place to encode your palette and type scale once, then use the utilities everywhere.

Fonts

Load a web font in styles.css and wire it into @theme:

styles.css
@import "tailwindcss"; @font-face { font-family: "Satoshi"; src: url("https://cdn.example.com/satoshi.woff2") format("woff2"); font-weight: 400 700; font-display: swap; } @theme { --font-sans: "Satoshi", ui-sans-serif, system-ui, sans-serif; }

font-display: swap avoids blocking first paint. Self-hosted files can be uploaded as assets (below) and referenced by URL.

Assets — useAsset

Images, logos, and other static files aren’t committed to the project tree. Upload them in the editor and reference them by logical name:

import { useAsset } from '@appfunnel-dev/sdk' function Logo() { const logo = useAsset('logo.svg') // the served URL for that asset return <img src={logo} className="h-6" alt="" /> }

useAssets() returns the whole { name → url } map if you need it. You upload a file in the editor, and Appfunnel serves it and makes it available to your funnel by name. useAsset(name) returns the URL, or undefined if that name wasn’t uploaded — so guard usage where a missing asset would break layout. See Preview for uploading in the editor.

Localized copy

Wrap user-facing strings in t() so they can be translated:

import { useTranslation } from '@appfunnel-dev/sdk' function Welcome() { const { t } = useTranslation() return ( <section> <h1>{t('welcome.title')}</h1> <p>{t('welcome.subtitle', { name: 'there' })}</p> {/* interpolates {{name}} */} </section> ) }

You don’t write the catalogs by hand — enabling localization in the editor extracts every hardcoded string in your pages into t() calls and generates the source messages/<locale>.json. Interpolation uses {{var}}; a blank or missing translation falls back to the source rather than rendering empty. The full workflow, including auto-detection and per-locale catalogs, is in Localization.

Modals and sheets

Modals are imperative and promise-based: define one, then show it from anywhere and await the result. There’s no provider to add — the runtime wires it.

Define a modal with defineModal, and render <Sheet> or <Modal> chrome inside it:

import { defineModal, Sheet, showModal } from '@appfunnel-dev/sdk' const Declined = defineModal(function Declined() { return ( <Sheet className="p-6"> <h2 className="text-lg font-semibold">Payment failed</h2> <p className="mt-2 text-slate-500">Your card was declined. Try another?</p> </Sheet> ) }) // From anywhere — an event handler, an onFailed route, an effect: showModal(Declined) // returns a Promise, resolved when the modal calls resolve()
  • <Sheet> is a slide-in panel (side="bottom" | "top" | "left" | "right", default bottom). <Modal> is a centered dialog.
  • Both portal to document.body, lock body scroll, and dismiss on Escape or backdrop click. They’re headless — style them with className.

<Sheet> and <Modal> only work inside a defineModal component — they auto-bind to the surrounding modal’s open state through context. Rendering <Sheet> directly in a page throws. For a locally-controlled overlay, still wrap it in defineModal and open it with showModal.

Toasts

toast is a direct re-export of sonner ’s imperative API. Call it anywhere:

import { toast } from '@appfunnel-dev/sdk' toast.success('Saved') toast.error('Something went wrong')

The runtime auto-mounts the toaster (top-center) for you. To control placement, render <FunnelToaster> yourself and it takes over from the auto-mounted one.

Third-party scripts — <Script>

For a third-party runtime widget that needs client JavaScript — a chat widget, a review badge, a calendar embed — use <Script>. It renders nothing, injects the script at most once (deduped by id/src, even across navigation), and persists across page swaps.

import { Script } from '@appfunnel-dev/sdk' <Script src="https://widget.intercom.io/widget/abc123" strategy="lazyOnload" />
PropWhat it does
srcThe script URL.
strategy'afterInteractive' (default, on mount) or 'lazyOnload' (waits for idle/load).
idDedup key; defaults to src.
async / deferStandard script attributes; async defaults to true.
attributesExtra attributes, e.g. data-*.
onLoad / onErrorLoad callbacks.

Declaring a script through <Script> is what allows it to load — Appfunnel only permits the script hosts you declare this way, so hand-injecting one from elsewhere is blocked.

<Script> is for non-analytics third-party widgets. For analytics pixels (Meta, GTM, TikTok, Clarity), don’t hand-inject a script — configure them as integrations instead, and Appfunnel fires the correct events for you. See Integrations & Data.

Last updated on