Skip to content

Icon

A lightweight icon wrapper around a synchronous icon registry for consistent rendering, sizing, and accessibility.

Features

  • Single API — consistent name, size, and a11y behavior
  • Accessible by default — decorative when unlabeled, semantic when label is set
  • Theme-friendly — uses currentColor so color is controlled with CSS
  • Flexible sizing — number (px) or CSS length values
  • Solid mode — enable solid for filled icon rendering

Source Code

View Source Code
ts
import { define, html, prop, raw } from '@vielzeug/craft';
import { computed } from '@vielzeug/ripple';
import * as lucideModule from 'lucide';

import { warn } from '../../_warn';
import styles from './icon.css?inline';

export type IconNode = Array<[string, Record<string, string | number | undefined>]>;

const DEFAULT_SIZE = 16;
const DEFAULT_STROKE_WIDTH = 2;
const LUCIDE_VIEWBOX_SIZE = 24;

/**
 * Allowlisted SVG shape/structure element names. Only these tags are permitted
 * when building the SVG markup from an IconNode, preventing arbitrary element
 * injection (e.g. <script>, event-handler-bearing elements).
 */
const ALLOWED_SVG_TAGS = new Set([
  'circle',
  'clipPath',
  'defs',
  'ellipse',
  'g',
  'line',
  'linearGradient',
  'mask',
  'path',
  'polygon',
  'polyline',
  'radialGradient',
  'rect',
  'stop',
  'symbol',
  'use',
]);

/**
 * Allowlisted SVG presentation / structural attribute names.
 * Event handler attributes (on*) and "xlink:href" are excluded.
 */
const ATTR_KEY_RE = /^[a-zA-Z][a-zA-Z0-9:_-]*$/;
const BLOCKED_ATTR_RE = /^(on|xlink:|xml:|href$)/i;

// Sync registry seeded from lucide at module load; extend at any time via registerIcons()
const registry = new Map<string, IconNode>(
  Object.entries((lucideModule as unknown as { icons: Record<string, IconNode> }).icons),
);

/**
 * Register additional icons (or override existing ones) by name.
 * Keys may be kebab-case or PascalCase — both are accepted by `sg-icon`.
 */
export function registerIcons(icons: Record<string, IconNode>): void {
  for (const [name, node] of Object.entries(icons)) {
    registry.set(name, node);
  }
}

const toAttrString = (attrs: Record<string, string | number>): string =>
  Object.entries(attrs)
    .filter(([k]) => ATTR_KEY_RE.test(k) && !BLOCKED_ATTR_RE.test(k))
    .map(
      ([k, v]) =>
        `${k}="${String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}"`,
    )
    .join(' ');

const toPascalCase = (value: string): string =>
  value
    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
    .split(/[^a-zA-Z0-9]+/)
    .filter(Boolean)
    .map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
    .join('');

const parseSize = (value: string | null): number | string => {
  if (value == null || value === '') return DEFAULT_SIZE;

  const n = Number(value);

  return Number.isFinite(n) && n > 0 && /^\d+(\.\d+)?$/.test(value.trim()) ? n : value;
};

/** Icon component properties */
export type SgIconProps = {
  /** Keep stroke width visually consistent when icon size changes */
  absoluteStrokeWidth?: boolean;
  /** Accessible text label. Decorative icons should omit this. */
  label?: string;
  /** Lucide icon name, e.g. `search` or `chevron-right` */
  name?: string;
  /** Icon width/height in px by default. Accepts CSS lengths. */
  size?: number | string;
  /** Render as a filled/solid shape instead of a stroked outline */
  solid?: boolean;
  /** SVG stroke width */
  strokeWidth?: number;
};

/**
 * Icon wrapper for consistent Lucide rendering across Block.
 *
 * @element sg-icon
 *
 * @attr {string} name - Lucide icon name, e.g. `search` or `chevron-right`
 * @attr {number|string} size - Width/height (default: 16)
 * @attr {number} stroke-width - SVG stroke width (default: 2)
 * @attr {boolean} absolute-stroke-width - Keep stroke width visually stable across icon sizes
 * @attr {boolean} solid - Render as filled shape instead of stroked outline
 * @attr {string} label - Accessible label; when omitted the icon is decorative
 *
 * @csspart svg - Internal SVG element
 *
 * @example
 * ```html
 * <sg-icon name="search"></sg-icon>
 * <sg-icon name="chevron-right" size="18"></sg-icon>
 * <sg-icon name="trash-2" label="Delete"></sg-icon>
 * <sg-icon name="star" solid></sg-icon>
 * ```
 */
export const ICON_TAG = 'sg-icon' as const;
define<SgIconProps>(ICON_TAG, {
  props: {
    absoluteStrokeWidth: prop.bool(),
    label: prop.string(),
    name: prop.string(),
    size: { default: DEFAULT_SIZE as number | string, parse: parseSize, reflect: false },
    solid: prop.bool(),
    strokeWidth: prop.number(DEFAULT_STROKE_WIDTH),
  },
  setup(props, { bind, el: _el }) {
    bind({
      attr: {
        'aria-hidden': () => ((props.label.value ?? '').trim() ? null : 'true'),
        'aria-label': () => (props.label.value ?? '').trim() || null,
        role: () => ((props.label.value ?? '').trim() ? 'img' : null),
      },
    });

    const markup = computed(() => {
      const name = (props.name.value ?? '').trim();

      if (!name) return '';

      const iconNode = registry.get(name) ?? registry.get(toPascalCase(name));

      if (!iconNode) {
        warn(`sg-icon: icon not found: "${String(name).slice(0, 64)}"`);

        return '';
      }

      const size = props.size.value;
      const cssSize = typeof size === 'number' ? `${size}px` : String(size);
      const numericSize = typeof size === 'number' ? size : LUCIDE_VIEWBOX_SIZE;

      const strokeWidth =
        props.absoluteStrokeWidth.value && !props.solid.value
          ? props.strokeWidth.value! * (LUCIDE_VIEWBOX_SIZE / numericSize)
          : props.strokeWidth.value!;

      const nodes = iconNode
        .filter(([tag]) => ALLOWED_SVG_TAGS.has(tag))
        .map(([tag, tagAttrs]) => {
          const attrs: Record<string, string | number> = {};

          for (const [k, v] of Object.entries(tagAttrs)) {
            if (v !== undefined) attrs[k] = v;
          }

          return `<${tag} ${toAttrString(attrs)} />`;
        })
        .join('');

      const svgAttrs = toAttrString({
        fill: props.solid.value ? 'currentColor' : 'none',
        part: 'svg',
        stroke: props.solid.value ? 'none' : 'currentColor',
        'stroke-linecap': 'round',
        'stroke-linejoin': 'round',
        'stroke-width': props.solid.value ? 0 : strokeWidth,
        style: `height:${cssSize};width:${cssSize}`,
        viewBox: '0 0 24 24',
        xmlns: 'http://www.w3.org/2000/svg',
      });

      return `<svg ${svgAttrs}>${nodes}</svg>`;
    });

    return html`${raw(markup)}`;
  },
  styles: [styles],
});

Basic Usage

html
<sg-icon name="search"></sg-icon>
<sg-icon name="chevron-right" size="18"></sg-icon>
<sg-icon name="trash-2" label="Delete"></sg-icon>
<sg-icon name="star" solid></sg-icon>

Registry (Option A)

sg-icon reads from a synchronous icon registry. The default registry is seeded from Lucide at module load.

You can register your own icons (or override existing ones) with registerIcons.

ts
import { registerIcons } from '@vielzeug/sigil/content';

registerIcons({
  BrandMark: [
    ['path', { d: 'M4 4h16v16H4z' }],
    ['circle', { cx: 12, cy: 12, r: 3 }],
  ],
});
html
<sg-icon name="brand-mark"></sg-icon>

Styling and Color

PreviewCode
RTL

Accessibility

  • If label is omitted, the icon is treated as decorative (aria-hidden="true").
  • If label is provided, the host gets role="img" and aria-label.

API Reference

Attributes

  • name: string, default undefined — Lucide icon name (for example search, chevron-right)
  • size: number | string, default 16 — Icon width/height
  • stroke-width: number, default 2 — SVG stroke width
  • absolute-stroke-width: boolean, default false — Keeps stroke width visually consistent on scale
  • solid: boolean, default false — Renders icon as a filled shape
  • label: string, default undefined — Accessible label; omit for decorative icons

Notes

  • There is no color attribute. Set icon color through CSS via currentColor.
  • Example: <span style="color: var(--color-success);"><sg-icon name="check"></sg-icon></span>

CSS Parts

PartDescription
svgInternal SVG element