ui-component
GitHub指导构建和扩展共享 Astro UI 组件,涵盖 Props 设计、类组合及文档同步。用于新增组件、修改属性或重构现有组件。
Trigger Scenarios
Install
npx skills add tabler/tabler --skill ui-component -g -y
SKILL.md
Frontmatter
{
"name": "ui-component",
"description": "Build or extend a shared Astro component in `shared\/ui\/*.astro` (or `shared\/components\/**`) — the library that both the preview and the docs render. Use whenever the user asks for a new component, for a new prop, variant or size on an existing one, or for a refactor of a shared component. Covers the Props interface, the prop-name dictionary, class composition, slots, the `as` pitfall, and the follow-ups (demo page, docs, changeset) a new component needs."
}
Build a shared UI component
shared/ui/*.astro is the component library. @tabler/preview and @tabler/docs render the same files, so every change here lands on demo pages and in documentation examples at once. Components are markup: the design lives in core/scss/, and logic longer than a few lines lives in shared/lib/*.ts.
1. Before creating a file
- Extend, do not fork. Read the
Propsinterface of the closest existing component first. A new look is a prop on the existing component (variant,size,light), never a parallelButtonAlt.astro. - Check the demo page before the component. If
preview/pages/<name>.astrohand-writes variants the component cannot express, those are the missing props, and the component has drifted from the markup it is supposed to own. Close that gap instead of adding one more hand-written block. - Find the CSS first. A component wraps classes that exist in
core/scss/ui/_*.scss. If the class is missing, writing it is part of the task — never invent class names in the component. - Where it goes: generic building blocks in
shared/ui/; fixed demo content (a dashboard card with its own data) inshared/components/cards/; docs-only components indocs/components/. - Imports inside
shared/are relative (./Icon.astro,../components/CaptureScript.astro).@shared/@ui/@dataare for the site packages. Any bare npm import used inshared/must be declared inshared/package.json.
2. Skeleton
---
import Icon from './Icon.astro'
/** core/scss/ui/_badges.scss `.badge-{size}` modifier classes */
type BadgeSize = 'sm' | 'lg'
interface Props {
text?: string
color?: string
size?: BadgeSize | undefined
/** -lt colour variant + text-{color}-lt-fg */
light?: boolean
class?: string
icon?: string
/** remaining attributes (id, data-*, aria-*, …) are forwarded to the root element */
[key: string]: unknown
}
const { text, color, size, light, class: className, icon, ...rest } = Astro.props
const classes = ['badge', size && `badge-${size}`, color && `bg-${color}${light ? '-lt' : ''}`, className]
---
<span class:list={classes} {...rest}>{icon && <Icon name={icon} />}{text}</span>
Rules the file above follows, and every component should:
interface Props+ destructuring with defaults. No untypedAstro.props.- The
classprop keeps its HTML name and is destructured asclass: className. - A union type for each modifier set (
'sm' | 'lg'), with a doc comment naming the SCSS file the classes come from. Export the type (export type IconSize) when a sibling component reuses it. [key: string]: unknown+...restspread on the root element, so callers can passid,data-*,aria-*without a prop for each. Add it unless the component deliberately controls its own attributes.- One doc comment per non-obvious prop, saying which class or attribute it emits.
3. Prop-name dictionary
Names are shared across the library so the API stays predictable (and stays portable to React/Vue later). Reuse these before inventing a name:
| Prop | Meaning |
|---|---|
class |
extra classes, merged last so callers can override |
id |
element id; required for components that a script initialises |
color |
palette colour (primary, azure, …), emitted as -{color} |
size |
sm / md / lg (…), where md emits no class |
variant |
style variant of the same component (outline, ghost, important) |
text, title, description, label |
content passed as a prop instead of a slot |
icon, iconEnd |
icon names, rendered through Icon.astro |
href, external |
link target; external adds target="_blank" rel="noreferrer" |
disabled, active, required, invalid |
state booleans, named after the HTML/CSS state |
show… (showClose, showValue) |
opt-in parts of the markup |
ariaLabel |
accessible name when the visible content is an icon or a colour |
Props are camelCase. Booleans default to false — never showClose = true.
4. Class composition
- Use
class:list={[...]}with falsy entries for conditionals; it drops them and merges the caller'sclassNamefor you..filter(Boolean).join(' ')is only for a string you have to pass to a helper (iconSvg). - Put
classNamelast so the caller wins. - Do not emit a class for a default (
size !== 'md' && \btn-${size}``). - No
<style>blocks and no CSS imports in the frontmatter. Styling iscore/scss/. A<style>makes Astro stampdata-astro-cid-*on every element the component renders, and its custom properties skip the--tblr-prefixing inbuild-css.ts.
5. Slots and containers
Container components (Card, CardBody, ButtonList, …) take a <slot /> rather than a content prop. Use Astro.slots.has('name') when the markup differs depending on whether a named slot was filled.
Polymorphic containers take an as prop — with one trap:
---
import type { HTMLTag } from 'astro/types'
// `as` must stay out of the Props body: Astro's frontmatter scanner bails on a member
// literally named `as` and silently falls back to untyped props.
type PolymorphicProps = { as?: HTMLTag }
interface Props extends PolymorphicProps {
class?: string
}
const { as: Element = 'div', class: className }: Props = Astro.props
---
<Element class:list={['card', className]}><slot /></Element>
Heading components pick their own level (CardTitle renders h2, as="h3" for a nested section) so pages cannot skip levels.
6. Escape hatches and Astro traps
- Content that may contain markup or entities (
…) →set:html/<Fragment set:html={…} />. Entities in attribute strings must be passed as an expression (title={"……"}), because JSX decodes them inside string literals. - Boolean attributes are inconsistent:
selected={true}renders bare, but some (e.g.multiple) render="true". Usemultiple ? '' : undefinedwhen a bare attribute is required, and check the rendered HTML. - Client-side behaviour (plugin init, event wiring) does not go in an ad-hoc
<script>— see theastro-scriptsskill. - Icons always through
<Icon name="…" />, never inline SVG. An unknown name renders nothing and only warns in the build log ([svg] unknown Tabler icon), so the page looks fine with a blank button. Check the name inshared/data/icons.jsonbefore using it: it isplayer-play, notplay.
7. Logic belongs in shared/lib
Anything beyond assembling classes and simple ternaries (formatting, data shaping, SVG handling, deterministic demo data) goes to shared/lib/*.ts and is imported by the component. Every module there has a sibling *.test.ts run by vitest (pnpm --filter @tabler/shared test) — add one for the function you introduce, and keep it pure so it is testable without a DOM.
Use pseudo-random.ts instead of Math.random() for demo data: pages are rebuilt constantly and random output would change the HTML on every build.
8. After the component exists
A new component is not done until it is visible and documented:
- Demo page — a page in
preview/pages/showing its variants (demo-pagesskill). - Docs page —
docs/content/**(write-docsskill), plus the class table (class-referenceskill) when the component introduces classes. - Changeset —
generate-changesetskill,minorfor a new component,patchfor a fix.
9. Checklist
- Extends an existing component instead of duplicating one
- Classes exist in
core/scss/; no invented class names -
interface Propstyped, prop names taken from the dictionary in section 3 -
class: classNamemerged last,...restforwarded - Accessible: semantic element,
ariaLabelwhere the content is an icon - Logic in
shared/libwith a test, not in the frontmatter -
pnpm run format:prettierandpnpm run type-checkclean at the repo root (full output, not a tail) - Demo page + docs page + changeset
Version History
- 340f719 Current 2026-09-22 21:47


