Skip to content

Styling

Honeycomb uses Tailwind CSS v4 with the Vite plugin, shadcn-svelte design tokens in OKLCH color space, and a dark-mode system powered by mode-watcher. All styling configuration lives in a single CSS file with no tailwind.config — Tailwind v4 moves everything into CSS.

Tailwind v4 replaces the PostCSS plugin with a first-class Vite plugin. It is registered in vite.config.ts alongside SvelteKit:

vite.config.ts
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });

There is no tailwind.config.js or postcss.config.js. All theme values are declared directly in CSS.

The single source of truth for design tokens is src/routes/layout.css. It is imported by the root +layout.svelte and referenced by the shadcn-svelte CLI via components.json.

src/routes/layout.css (top)
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));

Key points:

  • @import "tailwindcss" — the v4 replacement for the old @tailwind directives.
  • tw-animate-css — provides animation utility classes used by shadcn-svelte components (enter/exit transitions, accordion slides, etc.).
  • @custom-variant dark — defines dark mode as a class strategy. Elements inside a parent with the .dark class receive dark styles.

All colors use the OKLCH color space for perceptually uniform blending. Tokens are split into :root (light) and .dark overrides.

TokenOKLCH valueUsage
--backgroundoklch(0.98 0.005 270)Page background
--foregroundoklch(0.18 0.04 265)Default text
--primaryoklch(0.55 0.22 265)Buttons, links, active states
--primary-foregroundoklch(1 0 0)Text on primary
--secondaryoklch(0.95 0.02 270)Secondary surfaces
--mutedoklch(0.96 0.01 270)Disabled / subtle backgrounds
--muted-foregroundoklch(0.50 0.03 265)Placeholder text
--accentoklch(0.93 0.04 270)Hover highlights
--destructiveoklch(0.60 0.24 25)Error / delete states
--borderoklch(0.92 0.015 270)Borders and dividers
--ringoklch(0.55 0.22 265)Focus rings

The dark palette follows Material Design elevation principles — surfaces gain white-overlay lightness at higher elevations:

TokenOKLCH valueElevation note
--backgroundoklch(0.15 0.005 250)Base (#121212)
--cardoklch(0.22 0.005 250)1dp — 5% white overlay
--popoveroklch(0.27 0.005 250)8dp — 12% white overlay
--accentoklch(0.24 0.005 250)2dp — 7% white overlay
--foregroundoklch(0.92 0.005 250)87% white (high emphasis)
--muted-foregroundoklch(0.65 0.005 250)60% white (medium emphasis)

Tailwind v4 requires an explicit @theme inline block to map CSS variables into its utility system. This is what lets you write bg-primary or text-muted-foreground:

@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
/* ... all tokens mapped ... */
}

The base radius is --radius: 0.625rem (10px). Four derived sizes are generated from it.

Five chart tokens (--chart-1 through --chart-5) provide a coordinated palette for data visualizations. They shift between modes to maintain contrast on their respective backgrounds.

A dedicated set of --sidebar-* tokens allows the sidebar component to use its own surface, text, accent, and border colors independently from the main layout.

@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

Every element defaults to border-border and a semi-transparent focus outline. The body gets the semantic background and text colors.

The stylesheet defines several custom variants for styling component states from shadcn-svelte and Bits UI:

VariantMatches
data-open[data-state="open"] or [data-open]
data-closed[data-state="closed"] or [data-closed]
data-checked[data-state="checked"] or [data-checked]
data-unchecked[data-state="unchecked"] or [data-unchecked]
data-selected[data-selected]
data-disabled[data-disabled="true"] or [data-disabled]
data-active[data-state="active"] or [data-active]
data-horizontal[data-orientation="horizontal"]
data-vertical[data-orientation="vertical"]

Use them as Tailwind modifiers:

<div class="data-open:opacity-100 data-closed:opacity-0 transition-opacity">
...
</div>

Hides the scrollbar while keeping scroll functionality:

@utility no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
<div class="overflow-y-auto no-scrollbar">...</div>

The cn() function in src/lib/utils.ts merges class names using clsx for conditional logic and tailwind-merge to deduplicate conflicting Tailwind classes:

src/lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

Usage in components:

<script lang="ts">
import { cn } from "$lib/utils";
let { class: className, variant = "default" } = $props();
</script>
<button class={cn(
"rounded-lg px-4 py-2 font-medium",
variant === "primary" && "bg-primary text-primary-foreground",
variant === "outline" && "border border-input bg-background",
className
)}>
<slot />
</button>

Theme switching is handled by the mode-watcher package. It toggles the .dark class on the document root and persists the preference to localStorage.

Root +layout.svelte
<script>
import { ModeWatcher } from "mode-watcher";
</script>
<ModeWatcher />
<!-- rest of layout -->

To build a theme toggle, use the helpers from mode-watcher:

<script>
import { toggleMode, mode } from "mode-watcher";
</script>
<button onclick={toggleMode}>
{$mode === "dark" ? "Light" : "Dark"}
</button>

Honeycomb uses two icon libraries:

LibraryPackageUsage
Lucide@lucide/sveltePrimary icon set — clean, consistent line icons
Tabler@tabler/icons-svelteExtended set for specialized icons
<script>
import { Home, Settings, Bell } from "@lucide/svelte";
import { IconBrandGithub } from "@tabler/icons-svelte";
</script>
<Home class="size-5 text-muted-foreground" />
<IconBrandGithub class="size-5" />

Both libraries are tree-shakeable — only the icons you import are included in the bundle.

The scrollAnimate Svelte action in src/lib/utils/scroll-animate.ts provides scroll-triggered animations using IntersectionObserver.

TypeEffect
fade-upFade in + slide up (default)
fade-downFade in + slide down
fade-leftFade in + slide from left
fade-rightFade in + slide from right
scaleFade in + scale from 92%
blurFade in + deblur from 8px
<script>
import { scrollAnimate } from "$lib/utils/scroll-animate";
</script>
<div use:scrollAnimate>Fades up on scroll</div>
<div use:scrollAnimate={{ type: "scale", delay: 200 }}>Scales in after 200ms</div>

Pass stagger to animate child elements sequentially:

<ul use:scrollAnimate={{ type: "fade-up", stagger: 100 }}>
<li>First (0ms)</li>
<li>Second (100ms)</li>
<li>Third (200ms)</li>
</ul>
OptionTypeDefaultDescription
typeAnimationType"fade-up"Animation style
delaynumber0Delay before animation starts (ms)
durationnumber700Animation duration (ms)
thresholdnumber0.15IntersectionObserver visibility threshold
staggernumberDelay between each child (ms)
oncebooleantrueOnly animate once vs. every time element enters

The components.json at the project root configures the shadcn-svelte CLI:

components.json
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/routes/layout.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}

Add new UI primitives with:

Terminal window
npx shadcn-svelte@latest add button

Components are generated into $lib/components/ui/<name>/.