DEV Community

khalil la
khalil la

Posted on

Meet Semantic Components — A Modern Angular UI Library

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>
Enter fullscreen mode Exit fullscreen mode

In Semantic Components, it's scTooltipTrigger:

<!-- Semantic Components -->
<button scTooltipTrigger="Save changes">Save</button>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
readonly isOpen = signal(false);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode

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',
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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 NgModel or 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add the styles to your global stylesheet:

@import '@semantic-components/ui/styles';
@source "../node_modules/@semantic-components/ui";
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

No module registration. No forRoot(). Just import and use.


Links

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)