Feature: Support Arbitrary Icon Variants with Presets and Custom Variants

Author: ajnartCreated Jan 9, 2026Updated Jan 9, 2026

Feature: Support Arbitrary Icon Variants with Presets and Custom Variants

Overview

Currently, the icon system only supports a limited set of hardcoded variants (light, dark, wordmark.light, wordmark.dark). This feature request aims to refactor the system to support:

  1. Preset variants: default, light, dark, wordmark-default, wordmark-light, wordmark-dark
  2. Custom variants: Any arbitrary variant name (e.g., monochrome, outline, filled, branded, etc.)
  3. Bulletproof rendering: The system must be able to display any icon variant without breaking, even if the variant structure is unexpected
  4. Code reuse: Unify the logic for displaying icons between collection pages (/icons/[icon]) and community pages (/community/[icon]) to reduce duplication

Current Architecture & Data Flow

How Icons Are Currently Stored

Collection Icons (Main Repository)

Collection icons are stored in the repository with the following structure:

  1. File System: Icons are stored in three directories:

    • /svg/ - SVG format files
    • /png/ - PNG format files
    • /webp/ - WEBP format files
  2. Metadata Structure (metadata.json):

    json
    {
      "icon-name": {
        "base": "svg",
        "aliases": ["alias1", "alias2"],
        "categories": ["category1"],
        "update": {
          "timestamp": "2024-01-01T00:00:00Z",
          "author": { "id": 12345, "login": "username" }
        },
        "colors": {
          "light": "icon-name-light",
          "dark": "icon-name-dark"
        },
        "wordmark": {
          "light": "icon-name-wordmark-light",
          "dark": "icon-name-wordmark-dark"
        }
      }
    }
  3. File Naming Convention:

    • Base icon: icon-name.svg, icon-name.png, icon-name.webp
    • Light variant: icon-name-light.svg, icon-name-light.png, icon-name-light.webp
    • Dark variant: icon-name-dark.svg, icon-name-dark.png, icon-name-dark.webp
    • Wordmark variants follow the same pattern with -wordmark-light and -wordmark-dark suffixes
  4. URL Generation: URLs are constructed as ${BASE_URL}/${format}/${filename}.${format} where:

    • BASE_URL = https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons
    • format = svg, png, or webp
    • filename = the icon name or variant name from metadata

Community Icons (PocketBase Submissions)

Community icons are stored in PocketBase with a different structure:

  1. Storage: Files are uploaded to PocketBase's file storage system, which sanitizes and renames files automatically (e.g., icon.svg becomes icon_abc123xyz.svg)

  2. Database Structure (community_gallery collection):

    • name: Icon identifier
    • assets: Array of sanitized filenames (e.g., ["icon_abc123.svg", "icon_xyz789.png"])
    • extras: JSON object containing:
      json
      {
        "aliases": ["alias1"],
        "categories": ["category1"],
        "base": "svg",
        "colors": {
          "light": "original-light-filename.svg",
          "dark": "original-dark-filename.svg"
        },
        "wordmark": {
          "light": "original-wordmark-light.svg",
          "dark": "original-wordmark-dark.svg"
        }
      }
  3. URL Generation: URLs are full HTTP URLs to PocketBase file endpoints:

    • Base: ${PB_URL}/api/files/community_gallery/${recordId}/${sanitizedFilename}
    • The transformGalleryToIcon function in web/src/lib/community.ts converts PocketBase records to the Icon format used by the display components
  4. Filename Matching: Because PocketBase sanitizes filenames, the system uses findBestMatchingAsset() to match original filenames (stored in extras) to actual sanitized filenames (in assets array)

Current Display Logic

The IconDetails component (web/src/components/icon-details.tsx) is shared between both collection and community pages. It handles the differences through:

  1. Icon Type Detection:

    typescript
    const isCommunityIcon = !!communityData.mainIconUrl || 
      (typeof iconData.base === "string" && iconData.base.startsWith("http"))
    • Collection icons: iconData.base is a format string ("svg", "png", "webp")
    • Community icons: iconData.base is a full HTTP URL or mainIconUrl is set
  2. Variant Rendering (renderVariant function):

    • Collection icons: Constructs URL as ${BASE_URL}/${format}/${iconName}.${format}
    • Community icons: Searches assetUrls array to find matching URL by filename and format
    • The function receives iconName (which may be a variant filename) and theme (optional: "light" or "dark")
  3. Variant Display Sections:

    • Base icon: Always shown unless it matches a variant name
    • Light variant: Only shown if iconData.colors?.light exists
    • Dark variant: Only shown if iconData.colors?.dark exists
    • Wordmark section: Only shown if iconData.wordmark exists, then checks for light and dark properties
  4. Format Detection:

    • Collection icons: Determined by iconData.base (if "svg", shows SVG/PNG/WEBP; if "png", shows PNG/WEBP only)
    • Community icons: Extracted from assetUrls by file extension

Current Submission Flow

  1. Form Submission (web/src/components/advanced-icon-submission-form-tanstack.tsx):

    • User selects variants from hardcoded list: base, dark, light, wordmark, wordmark_dark
    • Files are uploaded to form state
    • On submit, extras object is built with structure:
      typescript
      {
        colors: { dark: filename, light: filename },
        wordmark: { light: filename, dark: filename }
      }
  2. PocketBase Upload:

    • All files are uploaded as assets array
    • PocketBase sanitizes filenames
    • After upload, the code updates extras with sanitized filenames by tracking asset index
  3. Import Script (scripts/import-icon.ts):

    • Fetches submission from PocketBase
    • buildTargets() function creates target file paths based on extras.colors and extras.wordmark
    • Downloads files and saves with naming convention: icon-name-variant.${ext}
    • buildMetadataVariants() converts targets back to metadata format with hardcoded key mapping

Problems with Current Implementation

1. Hardcoded Variant Types

Type System (web/src/types/icons.ts):

  • IconColors only has light?: string and dark?: string
  • IconWordmarkColors only has light?: string and dark?: string
  • Cannot represent custom variants like monochrome, outline, filled, etc.

Impact: Any variant that isn't light or dark cannot be stored or displayed.

2. Duplicated Logic Between Collection and Community

URL Resolution:

  • Collection icons: Simple string concatenation in renderVariant
  • Community icons: Complex array searching and filename matching
  • Both paths are embedded in the same function, making it hard to maintain

Variant Iteration:

  • Collection icons: Direct property access (iconData.colors.light, iconData.colors.dark)
  • Community icons: Same structure but different URL resolution
  • No unified way to iterate over all variants

3. Submission Form Limitations

Hardcoded Variants:

  • Form only allows selecting from 5 predefined variants
  • Cannot add custom variants
  • UI is tightly coupled to specific variant names

Submission Payload:

  • extras structure assumes only colors and wordmark with light/dark
  • Asset index tracking is fragile and assumes specific order

4. Import Script Limitations

Target Building:

  • buildTargets() only checks for colors.light, colors.dark, wordmark.light, wordmark.dark
  • Cannot handle arbitrary variant names
  • Hardcoded filename patterns (icon-name-light.${ext}, icon-name-wordmark-light.${ext})

Metadata Building:

  • buildMetadataVariants() uses hardcoded if/else chain to map variant keys
  • Cannot handle custom variant names
  • Assumes specific key patterns (wordmark-light, wordmark-dark)

5. Display Component Limitations

Variant Sections:

  • IconVariantsSection is called separately for each hardcoded variant
  • WordmarkSection only checks for wordmark.light and wordmark.dark
  • Cannot dynamically render arbitrary variants

Technical Details:

  • Only displays colors.light and colors.dark in the variants list
  • Only displays wordmark.light and wordmark.dark in wordmark list
  • Cannot show custom variants

Proposed Solution

1. Unified Variant Type System

New Type Structure:

typescript
// Variant definitions are stored in a separate file (variant-definitions.ts)
// The database stores a simple array of variant names
export type IconVariants = {
  [variantName: string]: string  // variant name -> filename (without extension)
}

export type Icon = {
  base: string | "svg" | "png" | "webp"
  aliases: string[]
  categories: string[]
  update: IconUpdate
  variants?: IconVariants  // Replaces colors - flexible object with any variant names
  wordmark?: IconVariants  // Replaces IconWordmarkColors - flexible object with any variant names
  // Keep colors and wordmark for backward compatibility during migration
  colors?: { light?: string; dark?: string }
  wordmark?: { light?: string; dark?: string }
}

Variant Definitions File: Create web/src/lib/variant-definitions.ts that defines:

  • Preset variants (default, light, dark, wordmark-default, wordmark-light, wordmark-dark)
  • Metadata for each preset (label, description, icon component)
  • Helper functions to get variant definitions (returns preset or generates default for custom variants)

Migration Strategy:

  • Support both old format (colors) and new format (variants) during transition
  • Variant definitions file provides metadata for preset variants
  • Custom variants get default metadata generated on-the-fly
  • All display code uses getVariantDefinition() to get metadata for any variant name

2. Variant Definitions System

Create Variant Definitions File (web/src/lib/variant-definitions.ts):

This file defines preset variants and provides utilities to work with both preset and custom variants:

  • Defines preset variants with metadata (label, description, icon component)
  • Provides getVariantDefinition() function that returns preset metadata or generates default for custom variants
  • Provides helper functions to group and categorize variants
  • Allows display components to render any variant (preset or custom) with appropriate metadata

3. Unified URL Resolution Utility

Create New Utility (web/src/lib/icon-url-resolver.ts):

This utility will handle URL resolution for both collection and community icons, eliminating duplication:

typescript
interface IconUrlContext {
  isCommunityIcon: boolean
  baseIconName: string
  baseFormat: string
  // For collection icons
  baseUrl?: string
  // For community icons
  assetUrls?: string[]
  mainIconUrl?: string
}

/**
 * Resolves the URL for an icon variant in a specific format
 * Works for both collection and community icons
 */
export function resolveIconUrl(
  context: IconUrlContext,
  variantName: string | null,  // null for base icon
  format: string
): string | null {
  if (context.isCommunityIcon) {
    return resolveCommunityIconUrl(context, variantName, format)
  } else {
    return resolveCollectionIconUrl(context, variantName, format)
  }
}

function resolveCollectionIconUrl(
  context: IconUrlContext,
  variantName: string | null,
  format: string
): string {
  const filename = variantName || context.baseIconName
  return `${context.baseUrl}/${format}/${filename}.${format}`
}

function resolveCommunityIconUrl(
  context: IconUrlContext,
  variantName: string | null,
  format: string
): string | null {
  const formatExt = format === "svg" ? "svg" : format === "png" ? "png" : "webp"
  
  if (!variantName) {
    // Base icon: return mainIconUrl or find by format
    if (context.mainIconUrl?.toLowerCase().endsWith(`.${formatExt}`)) {
      return context.mainIconUrl
    }
    return context.assetUrls?.find(url => 
      url.toLowerCase().endsWith(`.${formatExt}`)
    ) || context.mainIconUrl || null
  }
  
  // Variant: find by matching variant filename in assetUrls
  // variantName is the filename (without extension) from metadata
  return context.assetUrls?.find(url => {
    const urlFilename = url.split('/').pop()?.replace(/\.[^.]+$/, '') || ''
    return urlFilename.includes(variantName) && url.toLowerCase().endsWith(`.${formatExt}`)
  }) || null
}

Benefits:

  • Single source of truth for URL resolution
  • Easy to test and maintain
  • Can be reused by both collection and community pages
  • Handles edge cases in one place

4. Unified Variant Rendering

Refactor IconDetails Component:

Instead of hardcoded sections, create a unified variant rendering system that:

  • Uses getVariantDefinition() to get metadata for each variant
  • Renders preset variants with their defined icons and labels
  • Renders custom variants with generated default metadata
  • Groups variants by category (regular variants vs wordmark variants)
  • Sorts variants (preset first, then custom alphabetically)
typescript
import { getVariantDefinition, groupVariantsByCategory } from "@/lib/variant-definitions"

// Get all variant names from iconData
const allVariants = Object.keys(iconData.variants || {})
const allWordmarkVariants = Object.keys(iconData.wordmark || {})

// Sort: preset variants first, then custom variants alphabetically
const sortedVariants = allVariants.sort((a, b) => {
  const aDef = getVariantDefinition(a)
  const bDef = getVariantDefinition(b)
  if (aDef.preset && !bDef.preset) return -1
  if (!aDef.preset && bDef.preset) return 1
  return a.localeCompare(b)
})

const sortedWordmarkVariants = allWordmarkVariants.sort((a, b) => {
  const aDef = getVariantDefinition(`wordmark-${a}`)
  const bDef = getVariantDefinition(`wordmark-${b}`)
  if (aDef.preset && !bDef.preset) return -1
  if (!aDef.preset && bDef.preset) return 1
  return a.localeCompare(b)
})

// Render all variants dynamically
{sortedVariants.map((variantName) => {
  const variantDef = getVariantDefinition(variantName)
  const variantFilename = iconData.variants[variantName]
  
  return (
    <IconVariantsSection
      key={variantName}
      title={variantDef.label}
      description={variantDef.description}
      iconElement={<variantDef.icon className="w-4 h-4" />}
      availableFormats={availableFormats}
      icon={variantFilename || icon}
      iconData={iconData}
      handleCopy={handleCopyUrl}
      handleDownload={handleDownload}
      copiedVariants={copiedVariants}
      renderVariant={renderVariant}
    />
  )
})}

// Similar for wordmark variants...

Updated renderVariant Function:

typescript
const renderVariant = (
  format: string, 
  variantFilename: string | null,  // null for base icon
  variantKey: string
) => {
  const imageUrl = resolveIconUrl(urlContext, variantFilename, format)
  if (!imageUrl) return null  // Handle missing variants gracefully
  
  const githubUrl = !isCommunityIcon && variantFilename
    ? `${REPO_PATH}/tree/main/${format}/${variantFilename}.${format}`
    : ""
  
  // ... rest of rendering logic
}

Benefits:

  • Single rendering path for all variants
  • Automatically handles custom variants
  • No hardcoded variant names
  • Works for both collection and community icons

4. Enhanced Submission Form

Dynamic Variant Management:

  1. Preset Variants: Keep quick-select options for common variants (default, light, dark, wordmark-default, wordmark-light, wordmark-dark)

  2. Custom Variants: Add UI to:

    • Enter custom variant name (with validation: alphanumeric, hyphens, underscores)
    • Select variant type (regular variant or wordmark)
    • Upload file for that variant
    • Remove custom variants
  3. Updated Submission Payload:

    typescript
    {
      aliases: string[],
      categories: string[],
      base: string,
      variants?: { [variantName: string]: string },  // New format
      wordmark?: { [variantName: string]: string },  // New format
      // Keep old format for backward compatibility during migration
      colors?: { light?: string; dark?: string },
      wordmark?: { light?: string; dark?: string }
    }
  4. Asset Index Tracking: Instead of hardcoded index tracking, use a mapping:

    typescript
    // After upload, create a map of original filename -> sanitized filename
    const filenameMap = new Map<string, string>()
    value.files.base?.[0] && filenameMap.set(value.files.base[0].name, record.assets[0])
    // ... track all files
    
    // Then update extras by looking up in map
    Object.keys(extras.variants || {}).forEach(variantName => {
      const originalFilename = extras.variants[variantName]
      const sanitizedFilename = filenameMap.get(originalFilename)
      if (sanitizedFilename) {
        extras.variants[variantName] = sanitizedFilename
      }
    })

5. Enhanced Import Script

Dynamic Target Building:

typescript
function buildTargets(submission: Submission): VariantTarget[] {
  const iconId = submission.name
  const ext = inferBase(submission.assets, submission.extras?.base)
  
  const targets: VariantTarget[] = [
    { key: "base", destFilename: `${iconId}.${ext}` }
  ]
  
  // Handle new variants format
  const variants = submission.extras?.variants || {}
  Object.entries(variants).forEach(([variantName, filename]) => {
    if (filename) {
      targets.push({
        key: variantName,
        destFilename: `${iconId}-${variantName}.${ext}`,
        exactFilename: filename as string
      })
    }
  })
  
  // Handle wordmark variants
  const wordmarkVariants = submission.extras?.wordmark || {}
  Object.entries(wordmarkVariants).forEach(([variantName, filename]) => {
    if (filename) {
      targets.push({
        key: `wordmark-${variantName}`,
        destFilename: `${iconId}-wordmark-${variantName}.${ext}`,
        exactFilename: filename as string
      })
    }
  })
  
  // Migration: Support old colors format
  if (submission.extras?.colors && !submission.extras?.variants) {
    if (submission.extras.colors.light) {
      targets.push({
        key: "light",
        destFilename: `${iconId}-light.${ext}`,
        exactFilename: submission.extras.colors.light
      })
    }
    if (submission.extras.colors.dark) {
      targets.push({
        key: "dark",
        destFilename: `${iconId}-dark.${ext}`,
        exactFilename: submission.extras.colors.dark
      })
    }
  }
  
  return targets
}

Dynamic Metadata Building:

typescript
function buildMetadataVariants(assignments: VariantTarget[]): {
  variants?: IconVariants
  wordmark?: IconVariants
} {
  const variants: IconVariants = {}
  const wordmark: IconVariants = {}
  
  for (const v of assignments) {
    if (!v.sourceAsset) continue
    
    const baseName = v.destFilename.replace(/\.[^.]+$/, "")
    
    if (v.key === "base") {
      // Base icon, skip (handled separately)
      continue
    } else if (v.key.startsWith("wordmark-")) {
      // Wordmark variant: extract variant name
      const variantName = v.key.replace("wordmark-", "")
      wordmark[variantName] = baseName
    } else {
      // Regular variant
      variants[v.key] = baseName
    }
  }
  
  return {
    variants: Object.keys(variants).length ? variants : undefined,
    wordmark: Object.keys(wordmark).length ? wordmark : undefined
  }
}

6. Updated Community Library

Enhanced Transformation:

The transformGalleryToIcon function in web/src/lib/community.ts needs to handle the new variant structure:

typescript
function transformGalleryToIcon(item: CommunityGallery): any {
  // ... existing code ...
  
  // Process variants (new format) or colors (old format for migration)
  const variants = item.extras?.variants ? { ...item.extras.variants } : undefined
  if (variants && item.assets) {

Source: homarr-labs/dashboard-icons