After waiting so long for an Angular UI library that actually met my needs, I decided to stop waiting and build my own. The result is Semantic Components — an open-source Angular UI library built on Tailwind CSS, Angular CDK, and Angular Aria, heavily inspired by shadcn/ui.
GitHub: https://github.com/gridatek/semantic-components
Package: @semantic-components/ui
Website: https://semantic-components.com
Why Semantic Components?
The Angular ecosystem has always had fewer off-the-shelf UI options compared to React. Libraries like shadcn/ui, Radix, and Headless UI have raised the bar for what a component library can be — and Angular deserves the same quality.
Semantic Components is my attempt to bring that standard to Angular, while leaning fully into what makes Angular great.
Core Design Principles
Semantic
Every directive or component is named to describe its role in the interface, not just the feature it belongs to. Take the tooltip as an example. Angular Material gives you a single matTooltip directive:
<!-- Angular Material -->
<button matTooltip="Save changes">Save</button>
In Semantic Components, it's scTooltipTrigger:
<!-- Semantic Components -->
<button scTooltipTrigger="Save changes">Save</button>
scTooltipTrigger — because ScTooltip is already the component that renders the actual tooltip bubble. The directive on the button is not the tooltip — it's what triggers it. These are two different things, and the names reflect that. ScDrawerTrigger, ScSelectValue, ScSelectTrigger, ScSidebarBody — you know exactly what each piece does before reading a single line of docs.
This principle extends to the HTML elements themselves. When possible, components/directives are applied to the right native element rather than a generic <div>.
Declarative
The entire UI is described in the template — no imperative open(), close(), or DialogService.create() calls. Take the dialog as an example:
<div scDialogProvider [(open)]="isOpen">
<button scDialogTrigger scButton variant="outline">Open Dialog</button>
<ng-template scDialogPortal>
<div scDialog>
<button scDialogClose>
<svg siXIcon></svg>
<span class="sr-only">Close</span>
</button>
<div scDialogHeader>
<h2 scDialogTitle>Edit profile</h2>
<p scDialogDescription>Make changes to your profile here.</p>
</div>
<!-- content -->
<div scDialogFooter>
<button scButton variant="outline" (click)="isOpen.set(false)">Cancel</button>
<button scButton type="submit">Save changes</button>
</div>
</div>
</ng-template>
</div>
readonly isOpen = signal(false);
The open state is a signal. The trigger, the portal, the close button — all declared in the template. No service injection, no imperative show/hide, no ViewContainerRef gymnastics. You read the template and immediately understand the full structure of the dialog.
The naming reinforces this. ScDialog is not a service — it's the <div role="dialog"> element itself. In Angular Material, MatDialog is a service you inject and call .open() on. Here, scDialog is the thing rendered in the DOM. Same naming principle: the name describes exactly what the piece is, not what it does behind the scenes.
There is a tradeoff: scDialog requires an extra wrapper element in the DOM scDialogProvider. It acts as the coordination point between the trigger, the portal, and the close button — sharing state through Angular's DI tree. It's a conscious choice in favor of keeping everything in the template, at the cost of one extra <div> that you may need to style or account for in your layout.
Composable
Each component is a set of small, focused pieces that you assemble yourself. There are no magic [content] inputs or hidden <ng-content> slots — you write the structure, and the pieces plug into it.
The Select is a good example of how far this goes:
<div scSelect #select="scSelect" placeholder="Select a label">
<div scSelectTrigger aria-label="Label dropdown">
<span scSelectValue>
@if (displayIcon(); as icon) {
<svg scSelectItemIcon siTagIcon></svg>
}
<span class="truncate">{{ select.displayValue() }}</span>
</span>
</div>
<ng-template scSelectPortal>
<div scSelectPopup>
<div scSelectList>
@for (item of items; track item.value) {
<div scSelectItem [value]="item.value" [label]="item.label">
<svg scSelectItemIcon siTagIcon></svg>
<span>{{ item.label }}</span>
</div>
}
</div>
</div>
</ng-template>
</div>
- You own the structure — the trigger layout, the item layout, the icons, the display value
- You extend freely — want a custom empty state in the list? A header above the items? Just add it
- The library handles behavior — keyboard navigation, selection state, ARIA attributes — you handle the markup
This also composes across components. A button can be a drawer trigger, a tooltip trigger, and an icon button all at once:
<button scButton size="icon" scDrawerTrigger scTooltipTrigger="Open menu">
<svg siMenuIcon></svg>
</button>
One element. Three responsibilities. No wrappers.
The tradeoff is verbosity. Because you own the structure, you write more template code than you would with a batteries-included component that hides everything behind inputs. That's a deliberate choice — explicit over implicit. You always know what's in the DOM because you put it there.
Tailwind + CVA for Variants
The library follows the shadcn/ui design system — same CSS variables, same color tokens (bg-primary, text-muted-foreground, border-input…), same default styles. If you're already familiar with shadcn, the visual language is instantly recognizable.
Styles are written in Tailwind CSS and managed with class-variance-authority. This means:
- Predictable, overridable class names
- Consistent variants (
default,outline,ghost,destructive,link) across all components
export const buttonVariants = cva('inline-flex items-center justify-center rounded-lg border ...', {
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
outline: 'border-border bg-background hover:bg-muted',
ghost: 'hover:bg-muted hover:text-foreground',
destructive: 'bg-destructive/10 text-destructive',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 px-2.5',
sm: 'h-7 px-2.5 text-[0.8rem]',
lg: 'h-9 px-2.5',
icon: 'size-8',
},
},
});
Built on Solid Foundations
The rest of the library's design is guided by a few core principles:
Attribute selectors over element selectors. Instead of custom elements like <sc-button>, the library uses attribute selectors on native HTML. No extra wrapper elements, native accessibility roles preserved, and multiple components/directives can stack on the same element:
<button scButton variant="outline" scDrawerTrigger>Open</button>
Modern Angular, all the way down. Signals (input(), output(), computed()), standalone components, native control flow (@if, @for), inject(), and OnPush everywhere. Overlays and positioning are built on @angular/cdk. Accessible patterns like focus trapping and live regions use @angular/cdk/a11y and @angular/aria. Forms are signal-based. The library is also zoneless-compatible — no zone.js required. No legacy APIs, no NgModules.
@Directive({ selector: 'button[scButton]' })
export class ScButton {
readonly variant = input<ScButtonVariants['variant']>('default');
readonly size = input<ScButtonVariants['size']>('default');
readonly disabled = input<boolean, unknown>(false, { transform: booleanAttribute });
}
Accessible by default. Every component is built to pass WCAG AA minimums — proper ARIA attributes, full keyboard navigation, focus management on dialogs and drawers, and screen reader support. Where possible, this is powered by Angular CDK's accessibility primitives (@angular/cdk/a11y) and @angular/aria.
Tradeoffs
This library makes deliberate choices that prioritize the future of Angular over backwards compatibility. That means it is not for every project — and that's intentional.
- Zoneless only. The library is built for zoneless Angular apps.
-
OnPush only. All components use
ChangeDetectionStrategy.OnPush. -
Signal-based forms only. Form integrations are designed around signals, not
NgModelor reactive forms. -
No NgModules. Everything is standalone. There are no module exports, no
forRoot(), no compatibility shims for module-based apps.
What's in the Box
@semantic-components/ui — Core Library
50+ components:
| Category | Components |
|---|---|
| Actions | Button, Button Group, Link, Toggle, Toggle Group |
| Layout | Card, Separator, Aspect Ratio, Toolbar, Scroll Area, Typography |
| Forms | Input, Textarea, Checkbox, Radio Group, Switch, Select, Native Select, Label, Field, Input Group, Slider, Range Slider |
| Overlays | Dialog, Alert Dialog, Drawer, Sheet, Popover, Hover Card, Tooltip, Toast, Backdrop |
| Navigation | Breadcrumb, Pagination, Tabs, Menu, Menu Bar, Navigation Menu |
| Display | Alert, Badge, Avatar, Skeleton, Spinner, Progress, Kbd, Empty, Item |
| Data | Table, Accordion, Collapsible, Calendar, Date Picker, Time Picker |
| File | File Upload |
Icons: @semantic-icons/lucide-icons
Icons are distributed as Angular components from @semantic-icons/lucide-icons. Every icon is a standalone component you apply to an <svg> element:
<svg siStarIcon></svg>
<svg siUserIcon></svg>
<svg siArrowRightIcon></svg>
This approach is fully tree-shakable — only the icons you import end up in your bundle. No icon fonts, no sprite sheets.
Getting Started
npm install @semantic-components/ui
Add the styles to your global stylesheet:
@import '@semantic-components/ui/styles';
@source "../node_modules/@semantic-components/ui";
The @import brings in the CDK overlay styles and the shadcn-compatible CSS variables (colors, radius, spacing tokens). The @source tells Tailwind v4 to scan the library's files so its utility classes are included in your build.
Then import what you need directly in your standalone component:
import { ScButton, ScDialog, ScDialogBody, ScDialogTitle } from '@semantic-components/ui';
@Component({
imports: [ScButton, ScDialog, ScDialogBody, ScDialogTitle],
template: `
<button scButton>Open Dialog</button>
`,
})
export class MyComponent {}
No module registration. No forRoot(). Just import and use.
Links
- GitHub: https://github.com/gridatek/semantic-components
- npm: https://www.npmjs.com/package/@semantic-components/ui
- License: MIT
Feedback, stars, and contributions are very welcome. If you're building Angular apps and tired of fighting your UI library, give Semantic Components a try.
Top comments (0)