Styling
Tailwind CSS v4, CSS-first configuration, no tailwind.config.js.
Setup
assets/styles.css is three imports and nothing else:
@import "tailwindcss";
@import "./alpha/design-system.css"; /* scoped to #alpha */
@import "./home.css"; /* scoped to #home */
Both partials are scoped to an id, and that is load-bearing rather than
tidy. Anything targeting body, html or ::selection unscoped would follow
every business card and links page served from the same deployment. A rule in
home.css may only match inside #home; a rule in the Alpha design system may
only match inside #alpha.
Only what utilities cannot express belongs in either file: theme variables,
@keyframes, the home page's animated background layers and preview-stage
scaling, and the handful of element rules that style rawHtml output — markup
that arrives from a stored record and therefore cannot carry a class.
That is Tailwind v4's CSS-first entry point — it replaces the v3
@tailwind base; @tailwind components; @tailwind utilities; triple and the
JavaScript config file. There is no tailwind.config.js in this project and
adding one would have no effect; v4 customisation goes in CSS via @theme.
It is wired up in three steps:
vite.config.tsregisters the plugin:export default defineConfig({ plugins: [fresh(), tailwindcss()] });client.tsimports the stylesheet so Vite can see it and hot-reload it:// Import CSS files here for hot module reloading to work. import "./assets/styles.css";- The Fresh Vite plugin injects the built stylesheet into the page. Note that
routes/_app.tsxcontains no<link rel="stylesheet">— do not add one.
Content scanning is automatic in v4; there is no content: [] array to
maintain.
Theming: why colours are inline styles
config.primaryColor is a per-link value read from JSON at request time.
Tailwind generates classes at build time by scanning source files, so it can
never produce a class for a colour it has not seen. Every brand colour is
therefore applied as an inline style:
<div class="sticky top-0 z-10 h-28 md:h-40 …" style={{ backgroundColor: config.primaryColor }}>
Where primaryColor is applied:
| Surface | Property | Component(s) |
|---|---|---|
| Sticky header background | backgroundColor |
all three |
| Save Contact button | backgroundColor |
BusinessCard |
| Tile icon circles | backgroundColor |
LinksPage |
| Card action buttons | backgroundColor |
ShowcasePage |
| Feature checkmarks | color |
ShowcasePage |
| Social bar icons | color |
ShowcasePage |
The consequence: Tailwind variants do not apply to these properties. You
cannot write hover: or dark: against primaryColor. Components work around
it with opacity/transform utilities that are static — hover:opacity-90,
hover:scale-105, active:scale-[0.98] — which is the pattern to follow.
Everything that is not brand-coloured uses the standard Tailwind palette:
slate for surfaces and text, green-500 for WhatsApp, red-500 for errors,
blue/purple/indigo accents for icon chips.
v4 syntax in use
Several utilities in this codebase are v4 spellings and will look wrong if you are used to v3. Do not "fix" them:
| v4 (used here) | v3 equivalent | Where |
|---|---|---|
bg-linear-to-br |
bg-gradient-to-br |
every page background |
shrink-0 |
flex-shrink-0 |
BusinessCard, ShowcasePage |
grow |
flex-grow |
ShowcasePage cards |
Also present: arbitrary values (top-[72px], active:scale-[0.98]) and the
line-clamp-2 utility, which is core in v4 rather than a plugin.
Responsive strategy
Mobile-first. Base classes target phones; md: (768 px) is the primary
breakpoint, with lg: used only for the showcase-page card grid. Recurring
patterns:
The card shell — full-bleed on phones, floating sheet on desktop:
min-h-screen … md:flex md:items-center md:justify-center md:p-4
w-full md:max-w-md bg-white md:rounded-2xl md:shadow-xl … min-h-screen md:min-h-0
Header sizing — h-28 md:h-40, logos h-16 md:h-24 (h-12 md:h-16 on
showcase-page).
Thumb-reachable CTA — the Save Contact button is
fixed bottom-0 left-0 right-0 on mobile and md:relative on desktop, with
pb-16 md:pb-6 on the scroll area to clear it.
Progressive disclosure — the "Powered by onnne.link" strip is
hidden md:block.
Grid density — links-page stays at grid-cols-2 on all sizes;
showcase-page goes grid-cols-1 md:grid-cols-2 lg:grid-cols-3.
Hardcoded sticky offsets
ShowcasePage's social bar is positioned with top-[72px] md:top-[88px],
matching the rendered height of the header above it. These are magic numbers
coupled to the header's padding and logo height. If you change py-4 md:py-6
or the logo's h-12 md:h-16 in that header, you must recompute these offsets or
the two sticky bars will overlap.
JSX precompilation and the skip list
deno.json sets:
"jsx": "precompile",
"jsxImportSource": "preact",
"jsxPrecompileSkipElements": [
"a", "img", "source", "body", "html", "head", "title", "meta",
"script", "link", "style", "base", "noscript", "template", "lottie-player"
]
precompile mode compiles static JSX subtrees straight to HTML strings, which
is a large SSR speed win. The skip list names elements that must stay real
vnodes instead:
aandimg— Fresh needs them as vnodes to rewritehref/src(client-side navigation, asset hashing). These matter here: every page is mostly links and logos.html,head,body,title,meta,link,script, … — required so Fresh's<Head>component can hoist and merge head content from route files.lottie-player— a custom element the precompiler does not know; skipping it keeps the tag and its attributes intact.
If you introduce another custom element, add it to this list and declare it
in types.d.ts, the same way lottie-player is.
Formatting
deno fmt owns formatting for .tsx, .ts, .json, .md — 2-space indent,
double quotes, 80-column preference. deno task check runs deno fmt --check
and fails CI-style on drift, so run deno fmt . before committing,
including after editing these docs.
Long Tailwind class strings are left on one line; deno fmt does not wrap
string literals.
.vscode/settings.json sets the Deno extension as the default formatter for
TS/TSX/JS/JSX, and associates *.css with the tailwindcss language mode so
the Tailwind IntelliSense extension understands @import "tailwindcss".
Adding styles
In order of preference:
- Tailwind utility classes — the default for everything.
- Inline
style— only for values that come fromLinkConfigat runtime. @themeinassets/styles.css— for genuinely global design tokens (a brand font, a shared custom colour). This is the v4 way to extend the palette; there is currently no@themeblock.- Raw CSS in
assets/styles.css— last resort.
There are no CSS modules, no styled-components, and no component-scoped stylesheets.
Related
- Which components apply which colours → COMPONENTS.md
- What
primaryColorandlocalemean in the data → DATA-MODEL.md - Build pipeline → DEPLOYMENT.md