Icon
A lightweight icon wrapper around a synchronous icon registry for consistent rendering, sizing, and accessibility.
Features
Single API — consistent name,size, and a11y behaviorAccessible by default — decorative when unlabeled, semantic when labelis setTheme-friendly — uses currentColorso color is controlled with CSSFlexible sizing — number (px) or CSS length values Solid mode — enable solidfor 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')}"`,
)
.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
Accessibility
- If
labelis omitted, the icon is treated as decorative (aria-hidden="true"). - If
labelis provided, the host getsrole="img"andaria-label.
API Reference
Attributes
name:string, defaultundefined— Lucide icon name (for examplesearch,chevron-right)size:number | string, default16— Icon width/heightstroke-width:number, default2— SVG stroke widthabsolute-stroke-width:boolean, defaultfalse— Keeps stroke width visually consistent on scalesolid:boolean, defaultfalse— Renders icon as a filled shapelabel:string, defaultundefined— Accessible label; omit for decorative icons
Notes
- There is no
colorattribute. Set icon color through CSS viacurrentColor. - Example:
<span style="color: var(--color-success);"><sg-icon name="check"></sg-icon></span>
CSS Parts
| Part | Description |
|---|---|
svg | Internal SVG element |