Skip to main content
Urbicon UI

Customization

Every Urbicon UI component is restyleable through one predictable ladder of escape hatches. Pick the lowest rung that solves your problem — lower rungs preserve more of the design system's behavior (dark mode, hover/active cascade, focus rings).

Which tool do I use?

Start from your goal, not from the API. Find the row that matches what you want to change — the Reach for column is the tool to use.

I want to…Reach forExample
Restyle one element on one instance Highest priority. Merges onto the OUTERMOST (root) slot only — see the trap below.class<Button class="rounded-full">
Restyle an inner element (the actual <input>, a header, a chevron…) Type-safe — autocomplete lists the available slot names for each component.slotClasses.<slot><Input slotClasses={{ base: "rounded-full" }} />
App-wide look for a component type (every Button, every Card) defaults apply to every instance; presets are opt-in via preset="name".preset / BlocksProvider defaultsdefaults={{ Button: { slotClasses: { base: "rounded-full" } } }}
Style only one variant / intent / state (e.g. only outlined) Prop-conditional rule — what unconditional slotClasses cannot express.overridesoverrides: [{ variant: "outlined", class: { base: "border" } }]
Rebuild a component from scratch (strip every default) Renders the HTML structure only; you own all visuals.unstyled + slotClasses<Card unstyled slotClasses={{ base: "…" }} />
Full precedence chain (weakest → strongest): Conflicting Tailwind utilities are resolved per bucket, so a later source wins (e.g. an instance rounded-none defeats a default rounded-full); non-conflicting classes accumulate.
  1. tv() variant styles (library default)
  2. BlocksProvider defaults.slotClasses
  3. BlocksProvider defaults.overrides[match]
  4. preset.slotClasses (when preset="…" is set)
  5. preset.overrides[match]
  6. Instance slotClasses prop
  7. Instance class prop (root slot only)

The class Root-Slot Trap

The class prop only reaches the outermost (root) slot. Most components wrap several elements. class lands on the root wrapper, not the element you are usually picturing. To style something inside, go through slotClasses.<slot>.

The classic surprise is Input: its root slot is wrapper (the label + field column), and the real <input> element is the base slot. So class="rounded-full" rounds the column, not the field.

class vs. slotClasses on Input

<script>
  import { Input } from '@urbicon-ui/blocks';
</script>

<!-- ❌ Surprise: this rounds the WRAPPER (label + field column), not the field. -->
<Input label="Email" class="rounded-full" />

<!-- ✅ Reach the actual <input> via the `base` slot. -->
<Input label="Email" slotClasses={{ base: 'rounded-full' }} />

slotClasses is now type-safe on every component: the keys are derived from the component's tv() slots, so your editor autocompletes the available slot names (wrapper, container, base, label, message… for Input). Check a component's API reference, or the Slot Names reference, for its slot map.

CSS Token Themes

The simplest way to brand the library. Import a theme CSS file after the base styles to override the primary and secondary accents plus the neutral chassis. The semantic layer (surface, text, border tokens) derives from that chassis, so each theme re-tints it to match the accent's temperature — warm accents get warm surfaces, not cold grey ones.

Use a built-in theme

/* app.css */
@import '@urbicon-ui/blocks/style/index.css';
@import '@urbicon-ui/blocks/style/themes/ocean.css';
Ocean
Forest
Sunset
Rose
Neutral

You can also create your own. Use the Theme Builder to generate a matched OKLCH palette, or follow the annotated walkthrough on CSS Token Themes.

A brand color alone is not a theme. Recolor --color-primary-* and nothing else, and your warm brand button ends up sitting on cool blue-grey cards — surface-*, text-* and border-* derive from the neutral chassis, not from primary. A real theme also re-tints --color-neutral-* to the accent's temperature, and re-tunes any intent your accent collides with (a green brand vs. success, an amber one vs. warning).

Rather than paraphrase that, here is a shipped theme in full — this is blocks/style/themes/forest.css itself, read straight from the package. Note what it does beyond the two accent ramps: the chassis re-tint, the two collision re-tunes (green primary pushes success off 140; lime secondary pushes warning off 80), and the two :root chroma knobs at the end.

forest.css — a complete theme, verbatim from the package

/*
 * @urbicon-ui/blocks – Forest Theme
 *
 * Earthy green palette inspired by natural environments.
 * Import AFTER the base styles:
 *
 *   @import '@urbicon-ui/blocks/style/index.css';
 *   @import '@urbicon-ui/blocks/style/themes/forest.css';
 */

@theme {
  --color-primary-50: oklch(0.95 0.03 155);
  --color-primary-100: oklch(0.9 0.05 155);
  --color-primary-200: oklch(0.82 0.08 155);
  --color-primary-300: oklch(0.74 0.1 155);
  --color-primary-400: oklch(0.66 0.12 155);
  --color-primary-500: oklch(0.58 0.13 155);
  --color-primary-600: oklch(0.5 0.13 155);
  --color-primary-700: oklch(0.42 0.11 155);
  --color-primary-800: oklch(0.34 0.09 155);
  --color-primary-900: oklch(0.26 0.06 155);
  --color-primary-950: oklch(0.17 0.04 155);

  --color-secondary-50: oklch(0.95 0.02 90);
  --color-secondary-100: oklch(0.9 0.04 90);
  --color-secondary-200: oklch(0.82 0.06 90);
  --color-secondary-300: oklch(0.74 0.08 90);
  --color-secondary-400: oklch(0.66 0.1 90);
  --color-secondary-500: oklch(0.55 0.08 90);
  --color-secondary-600: oklch(0.48 0.08 90);
  --color-secondary-700: oklch(0.4 0.07 90);
  --color-secondary-800: oklch(0.32 0.05 90);
  --color-secondary-900: oklch(0.25 0.04 90);
  --color-secondary-950: oklch(0.18 0.03 90);

  /*
   * Chassis neutral ramp — re-tinted toward a green-grey stone (Hue 150) so
   * surfaces, text and borders read as natural/earthy rather than cool blue
   * (the library default neutral sits at Hue 240). Lightness and chroma
   * match the foundation ramp one-to-one — only the hue shifts — so WCAG
   * contrast stays unchanged. surface-*, text-* and border-* pick this up
   * automatically through semantic.css.
   */
  --color-neutral-25: oklch(0.985 0.003 150);
  --color-neutral-50: oklch(0.965 0.006 150);
  --color-neutral-100: oklch(0.95 0.008 150);
  --color-neutral-200: oklch(0.89 0.012 150);
  --color-neutral-300: oklch(0.83 0.014 150);
  --color-neutral-400: oklch(0.7 0.015 150);
  --color-neutral-500: oklch(0.55 0.016 150);
  --color-neutral-600: oklch(0.42 0.017 150);
  --color-neutral-650: oklch(0.38 0.016 150);
  --color-neutral-700: oklch(0.32 0.016 150);
  --color-neutral-750: oklch(0.28 0.014 150);
  --color-neutral-800: oklch(0.23 0.015 150);
  --color-neutral-850: oklch(0.18 0.014 150);
  --color-neutral-900: oklch(0.15 0.012 150);
  --color-neutral-950: oklch(0.08 0.008 150);

  /*
   * Intent re-tuning — the green primary (Hue 155) collides with the default
   * success (Hue 140), and the lime secondary (Hue 90) collides with the
   * default warning (Hue 80). Success is pushed to a teal-green (Hue 172) so a
   * "saved" state reads as verification rather than brand; warning is pulled
   * to amber (Hue 60) so it leaves the lime register. Same L/C profile as the
   * foundation ramps; only the hue shifts. Danger (25) and info (220) keep the
   * library defaults. Derived --color-success / --color-warning re-resolve at
   * :root automatically (global @theme).
   */
  --color-success-50: oklch(0.95 0.03 172);
  --color-success-100: oklch(0.9 0.05 172);
  --color-success-200: oklch(0.82 0.08 172);
  --color-success-300: oklch(0.74 0.11 172);
  --color-success-400: oklch(0.62 0.14 172);
  --color-success-500: oklch(0.5 0.15 172);
  --color-success-600: oklch(0.44 0.14 172);
  --color-success-700: oklch(0.38 0.12 172);
  --color-success-800: oklch(0.3 0.1 172);
  --color-success-900: oklch(0.22 0.08 172);
  --color-success-950: oklch(0.14 0.05 172);

  --color-warning-50: oklch(0.95 0.05 60);
  --color-warning-100: oklch(0.9 0.08 60);
  --color-warning-200: oklch(0.82 0.1 60);
  --color-warning-300: oklch(0.74 0.12 60);
  --color-warning-400: oklch(0.66 0.14 60);
  --color-warning-500: oklch(0.75 0.15 60);
  --color-warning-600: oklch(0.65 0.15 60);
  /* 0.59 mirrors foundation.css: light-mode press fill under the dark
     text-on-warning — hue 60 is the binding case (0.58 = 4.46:1, under AA). */
  --color-warning-700: oklch(0.59 0.13 60);
  --color-warning-800: oklch(0.45 0.11 60);
  --color-warning-900: oklch(0.25 0.08 60);
  --color-warning-950: oklch(0.15 0.05 60);
}

/*
 * Earthy shadow tint — a faint deep green-grey so shadows sit naturally on the
 * stone chassis. :root (not @theme) to mirror semantic.css; imported after the
 * base styles, so it wins.
 */
:root {
  --blocks-shadow-tint: 0.2 0.025 150;
  /* Neutral intent (bg-neutral / text-neutral / borders) follows the stone
     chassis instead of the library-default cool grey. */
  --neutral-chrome-hue: 150;
}

Global Component Defaults

When CSS token overrides are not enough, use BlocksProvider to set default slotClasses for every component type. Wrap your app once, and every Button, Card, Input etc. picks up the defaults. Instance-level slotClasses still override the global ones.

Global defaults via BlocksProvider

<script>
  import { BlocksProvider } from '@urbicon-ui/blocks';
</script>

<BlocksProvider
  defaults={{
    Button: {
      slotClasses: { base: 'rounded-full font-bold uppercase tracking-wide' }
    },
    Card: {
      slotClasses: { base: 'rounded-3xl' }
    },
    Input: {
      slotClasses: { base: 'rounded-full' }
    }
  }}
>
  <slot />
</BlocksProvider>

Defaults sit near the bottom of the precedence chain above — instance slotClasses and class still win. For prop-conditional defaults (overrides) and named presets, see the BlocksProvider API.

Global Unstyled Mode

For a completely custom design, set unstyled on BlocksProvider. All components strip their default styles and only render the HTML structure. Use slotClasses (globally via defaults or per instance) to apply your own design.

Global unstyled mode

<script>
  import { BlocksProvider } from '@urbicon-ui/blocks';
</script>

<BlocksProvider unstyled>
  <!-- All components render without default styles -->
  <slot />
</BlocksProvider>

Deep Dives