Back to 0fee
0fee

UI Polish: From Functional to Premium

How 0fee.dev evolved from functional to premium with SVG icons, custom Select components, card styling, and syntax highlighting.

Thales & Claude | March 25, 2026 10 min 0fee
ui-designsvg-iconscustom-componentspremium-stylingpolish

A payment platform that handles real money needs to look like it handles real money. The early versions of 0fee.dev's dashboard were functional -- they displayed data, accepted input, and processed payments. But they used native HTML selects, emoji icons, unstyled code blocks, and exposed email addresses. Over several sessions, we transformed the dashboard from functional to premium -- the kind of interface that makes developers trust the platform with their payment processing.

This article covers five major polish initiatives: replacing emojis with professional SVG icons, building a custom Select component for 30+ dropdowns, applying premium card styling across 19 pages, protecting email addresses from spam bots, and adding syntax highlighting with line numbers.

From Emojis to Professional SVG Icons (Session 051)

The initial dashboard used emoji characters as icons. The AI features page had a brain emoji for Smart Routing, a shield emoji for Fraud Detection, and a sparkles emoji for Coming Soon. The About page used target, globe, and lightbulb emojis for company values.

Emojis have three problems in a professional application:

  1. Platform inconsistency. The same emoji looks different on iOS, Android, Windows, and Linux
  2. Size limitations. Emojis cannot be scaled, colored, or animated like SVGs
  3. Professional perception. Emojis signal casual communication, not enterprise payment processing

Session 051 replaced every emoji with a custom SVG icon component:

tsx// Before (emoji)
<span class="text-2xl">🧠</span>
<span>Smart Routing</span>

// After (SVG component)
<BrainIcon class="w-8 h-8 text-emerald-400" />
<span>Smart Routing</span>

SVG Icon Components

Each icon is a SolidJS component wrapping an inline SVG:

tsxfunction BrainIcon(props: { class?: string }) {
  return (
    <svg
      class={props.class}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    >
      <path d="M12 2a4 4 0 0 1 4 4c0 1.1-.9 2-2 2h-4a2 2 0 0 1-2-2 4 4 0 0 1 4-4z" />
      <path d="M12 8v14" />
      <path d="M8 12h8" />
      {/* ... additional paths */}
    </svg>
  );
}

Icons replaced across two pages:

PageEmojis ReplacedSVG Icons Added
AI.tsx6BrainIcon, ShieldIcon, RefreshIcon, ChartIcon, RobotIcon, SparklesIcon
About.tsx6TargetIcon, GlobeIcon, LightbulbIcon, HandshakeIcon, BoltIcon, UnlockIcon

The SVG icons inherit the text color via stroke="currentColor", meaning they automatically adapt to dark mode without any additional styling.

Status Page Fix

During the same session, a bug was discovered on the Status page where provider.icon was undefined, causing render failures. The fix used provider.letter (first letter of the provider name) with colored backgrounds instead:

tsx// Before (broken)
<span>{provider.icon}</span>

// After (working)
<div class={`w-10 h-10 rounded-full flex items-center justify-center
             text-white font-bold ${getProviderColor(provider.id)}`}>
  {provider.letter}
</div>

Custom Select Component: 30+ Native Selects Replaced (Session 056)

The HTML <select> element is one of the hardest elements to style consistently across browsers. On Chrome, it looks like a Chrome dropdown. On Safari, it looks like a Safari dropdown. On Firefox, it looks like a Firefox dropdown. None of them match a custom design system.

Session 056 created a custom Select component and replaced all 30 native <select> elements across 16 files.

The Select Component

tsx// frontend/src/components/ui/Select.tsx

interface SelectOption {
  value: string;
  label: string;
  icon?: string;
  badge?: string;
  description?: string;
  color?: string;
}

interface SelectProps {
  label?: string;
  placeholder?: string;
  options: SelectOption[];
  value: string | undefined;
  onChange: (value: string) => void;
  error?: string;
  disabled?: boolean;
}

function Select(props: SelectProps) {
  const [isOpen, setIsOpen] = createSignal(false);
  const [search, setSearch] = createSignal("");
  let dropdownRef: HTMLDivElement;

  // Close on click outside
  const handleClickOutside = (e: MouseEvent) => {
    if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
      setIsOpen(false);
    }
  };

  onMount(() => document.addEventListener("click", handleClickOutside));
  onCleanup(() => document.removeEventListener("click", handleClickOutside));

  // Keyboard navigation
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === "Escape") setIsOpen(false);
    if (e.key === "ArrowDown") navigateOptions(1);
    if (e.key === "ArrowUp") navigateOptions(-1);
    if (e.key === "Enter") selectHighlighted();
  };

  const selectedOption = () =>
    props.options.find(o => o.value === props.value);

  return (
    <div ref={dropdownRef} class="relative" onKeyDown={handleKeyDown}>
      {/* Label */}
      <Show when={props.label}>
        <label class="block text-sm font-medium text-gray-700
                      dark:text-gray-300 mb-1">
          {props.label}
        </label>
      </Show>

      {/* Trigger */}
      <button
        type="button"
        class="w-full flex items-center justify-between px-3 py-2
               bg-white dark:bg-gray-800
               border border-gray-300 dark:border-gray-600
               rounded-lg text-sm cursor-pointer
               hover:border-gray-400 dark:hover:border-gray-500
               focus:ring-2 focus:ring-emerald-500"
        onClick={() => setIsOpen(!isOpen())}
        disabled={props.disabled}
      >
        <span class="flex items-center gap-2 truncate">
          <Show when={selectedOption()?.icon}>
            <span>{selectedOption().icon}</span>
          </Show>
          <Show when={selectedOption()?.color}>
            <span
              class="w-3 h-3 rounded-full"
              style={{ background: selectedOption().color }}
            />
          </Show>
          <span class={selectedOption()
            ? "text-gray-900 dark:text-gray-100"
            : "text-gray-500 dark:text-gray-400"
          }>
            {selectedOption()?.label || props.placeholder || "Select..."}
          </span>
        </span>
        <ChevronDownIcon class={`w-4 h-4 text-gray-400 transition-transform
          ${isOpen() ? "rotate-180" : ""}`} />
      </button>

      {/* Dropdown */}
      <Show when={isOpen()}>
        <div class="absolute z-50 w-full mt-1
                    bg-white dark:bg-gray-800
                    border border-gray-200 dark:border-gray-700
                    rounded-lg shadow-lg
                    max-h-60 overflow-y-auto
                    animate-fade-in">
          <For each={props.options}>
            {(option) => (
              <button
                type="button"
                class={`w-full text-left px-3 py-2.5 text-sm flex items-center gap-2
                  ${option.value === props.value
                    ? "bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400"
                    : "text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
                  }`}
                onClick={() => {
                  props.onChange(option.value);
                  setIsOpen(false);
                }}
              >
                <Show when={option.icon}>
                  <span class="text-base">{option.icon}</span>
                </Show>
                <Show when={option.color}>
                  <span
                    class="w-3 h-3 rounded-full shrink-0"
                    style={{ background: option.color }}
                  />
                </Show>
                <div class="flex-1 min-w-0">
                  <span class="block truncate">{option.label}</span>
                  <Show when={option.description}>
                    <span class="block text-xs text-gray-500 dark:text-gray-500 truncate">
                      {option.description}
                    </span>
                  </Show>
                </div>
                <Show when={option.badge}>
                  <span class="ml-auto shrink-0 px-1.5 py-0.5 text-xs rounded
                               bg-gray-100 dark:bg-gray-700
                               text-gray-600 dark:text-gray-400">
                    {option.badge}
                  </span>
                </Show>
              </button>
            )}
          </For>
        </div>
      </Show>
    </div>
  );
}

Features

FeatureImplementation
IconsOptional icon per option (flags, status icons)
Color badgesColored dots for status indicators
DescriptionsSecondary text below option label
BadgesRight-aligned badge text (counts, labels)
Keyboard navigationArrow keys, Enter, Escape
Click outside to closeDocument click listener with cleanup
Dark mode44+ dark utility classes
TruncationLong labels truncated with ellipsis

Replacement Scope

The custom Select replaced native selects in 16 files:

FileSelects ReplacedContext
DeveloperConsole.tsx3App, API Key, Currency
Transactions.tsx2Status filter, Provider filter
Apps.tsx5Status, Environment, Provider, Roles
Customers.tsx2Country, Sort
Settings.tsx1Timezone
Webhooks.tsx2App, Alert threshold
AddFunds.tsx2Currency, Country
PaymentLinks.tsx1Currency
PaymentMethods.tsx2Operator, Country
Invoices.tsx1Transaction
Countries.tsx1Region
FeatureRequests.tsx4Status, Category, Sort, Priority
Contact.tsx1Subject
PaymentLinkPage.tsx2Country, Currency
PaymentMethodsPage.tsx1Category
CountriesCovered.tsx1Region

Premium Card Styling (Session 068)

Session 068 applied consistent card styling across all 19 dashboard pages, transforming the visual quality from functional to premium:

The Pattern

tsx// Before: Inconsistent card styling
<div class="bg-white rounded-lg shadow p-4">

// After: Premium card styling
<div class="bg-white dark:bg-gray-900
            rounded-xl
            border border-gray-300 dark:border-gray-700
            shadow-sm p-6">

The key changes:

PropertyBeforeAfter
Border radiusrounded-lg (8px)rounded-xl (12px)
BorderNone (shadow only)border border-gray-300
Shadowshadow (medium)shadow-sm (subtle)
Paddingp-4 (16px)p-6 (24px)
Backgroundbg-white onlybg-white dark:bg-gray-900

Additional Polish

Session 068 also refined the sidebar and header:

Sidebar: - Menu items received larger icons (lg size) and taller touch targets (py-3.5) - "Dashboard" renamed to "Dash" for compact display - Collapsed width standardized to 72px - Scrollbar changed to overlay style

Header: - Search bar repositioned to center with flex layout - Visual separators added between action button groups - Button heights unified to h-8 - Subtle bottom border and shadow added

App background: - Changed from bg-gray-50 to bg-gray-100 (light mode) - Added dark:bg-gray-950 (dark mode)

Email Protection from Spam Bots (Session 052)

Email addresses displayed on the marketing pages (Contact, About, Pricing) were vulnerable to harvesting by spam bots that crawl HTML for mailto: links and @ symbols.

Session 052 created a ProtectedEmail component:

tsxfunction ProtectedEmail(props: { user: string; domain: string; class?: string }) {
  const [revealed, setRevealed] = createSignal(false);

  const email = () => `${props.user}@${props.domain}`;

  return (
    <span
      class={`cursor-pointer ${props.class}`}
      onClick={() => {
        setRevealed(true);
        window.location.href = `mailto:${email()}`;
      }}
    >
      <Show
        when={revealed()}
        fallback={<span>{props.user} [at] {props.domain}</span>}
      >
        {email()}
      </Show>
    </span>
  );
}

The strategy: 1. Display user [at] domain in static HTML (bots cannot parse this) 2. Construct the real email via JavaScript on click (bots do not execute JS) 3. Open the mailto link dynamically

Additionally, helper functions in company.ts were updated:

typescriptexport const SUPPORT_EMAIL_PARTS = { user: "0fee", domain: "zerosuite.dev" };

export function getObfuscatedEmail(): string {
  return `${SUPPORT_EMAIL_PARTS.user} [at] ${SUPPORT_EMAIL_PARTS.domain}`;
}

Syntax Highlighting with Line Numbers (Session 052)

Code blocks on the documentation pages, GetStarted page, and DeveloperConsole were unstyled <pre><code> blocks with monospace text on a gray background. Session 052 created a comprehensive SyntaxHighlighter component:

tsxfunction SyntaxHighlighter(props: {
  code: string;
  language: string;
  showLineNumbers?: boolean;
}) {
  const highlighted = () => tokenize(props.code, props.language);

  return (
    <div class="relative rounded-lg overflow-hidden bg-slate-900">
      {/* Copy button */}
      <button
        class="absolute top-2 right-2 px-2 py-1 text-xs
               bg-slate-800 hover:bg-slate-700
               text-gray-400 rounded"
        onClick={() => navigator.clipboard.writeText(props.code)}
      >
        Copy
      </button>

      <div class="flex overflow-x-auto max-h-96">
        {/* Line numbers */}
        <Show when={props.showLineNumbers !== false}>
          <div class="sticky left-0 select-none px-3 py-4
                      text-right text-xs text-gray-600
                      bg-slate-900 border-r border-slate-800">
            <For each={props.code.split("\n")}>
              {(_, i) => <div>{i() + 1}</div>}
            </For>
          </div>
        </Show>

        {/* Code content */}
        <pre class="flex-1 px-4 py-4 text-sm overflow-x-auto">
          <code>
            <For each={highlighted()}>
              {(token) => (
                <span class={getTokenColor(token.type)}>
                  {token.value}
                </span>
              )}
            </For>
          </code>
        </pre>
      </div>
    </div>
  );
}

Color Scheme

Token TypeColorTailwind Class
KeywordsPurpletext-purple-400
StringsEmeraldtext-emerald-400
Numbers, booleans, nullAmbertext-amber-400
Function callsBluetext-blue-400
Object propertiesCyantext-cyan-400
OperatorsPinktext-pink-400
CommentsSlatetext-slate-500

The tokenizer supports TypeScript, Python, Bash/cURL, and JSON -- the four languages most commonly used in 0fee's documentation and code examples.

The Cumulative Effect

Each polish session built on the previous ones:

SessionChangeImpact
051SVG icons replace emojisProfessional appearance
052Syntax highlighting + email protectionDeveloper trust, spam prevention
056Custom Select componentConsistent, branded dropdowns
068Premium card stylingPremium visual quality
078Dark mode across all pagesDeveloper preference support

By Session 078, the dashboard had transformed from a functional prototype to a premium-quality interface comparable to Stripe's dashboard, Twilio's console, or Google Cloud's management panel.

What We Learned

UI polish taught us three things:

  1. Polish is not one big effort -- it is many small ones. No single session transformed the dashboard. Each session addressed one category of visual improvement. The cumulative effect was dramatic, but each individual session was manageable.
  1. Shared components multiply polish. The custom Select component was built once and deployed to 30+ locations. The SyntaxHighlighter appears in four different contexts. Building reusable, polished components is more efficient than polishing individual pages.
  1. Professional details build trust in financial products. Developers evaluating a payment platform look at the dashboard quality as a proxy for engineering quality. If the UI is sloppy, they wonder if the payment processing is sloppy too. Polish is not vanity -- it is a trust signal.

This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles