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:
@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:
@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:
@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 withclassName.
<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" />| Prop | What it does |
|---|---|
src | The script URL. |
strategy | 'afterInteractive' (default, on mount) or 'lazyOnload' (waits for idle/load). |
id | Dedup key; defaults to src. |
async / defer | Standard script attributes; async defaults to true. |
attributes | Extra attributes, e.g. data-*. |
onLoad / onError | Load 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.