npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@grass-ds/angular

v1.0.0

Published

Angular implementation of the Grass Design System: attribute components on native elements and composite elements, styled by @grass-ds/css.

Readme

@grass-ds/angular

Angular implementation of the Grass Design System. Components are standalone attribute components on native elements (<button grButton>) and composite elements (<gr-card>), styled entirely by @grass-ds/css; this package owns behaviour, never appearance.

Install

npm install @grass-ds/angular @grass-ds/css @grass-ds/tokens

@grass-ds/css and @angular/core are peer dependencies. @angular/forms is an optional one, needed only by the entry points that touch forms (@grass-ds/angular/text-field, @grass-ds/angular/checkbox); an app created without it needs npm install @angular/forms before using them. Load the stylesheets once, for example in angular.json styles:

["@grass-ds/tokens/tokens.css", "@grass-ds/css/fonts.css", "@grass-ds/css/grass.css"]

fonts.css is optional. All packages share one version, so upgrade them together. @grass-ds/css and @grass-ds/tokens must be the same version as this package.

The exports of the three entry points (@grass-ds/angular, /text-field, /checkbox), their selectors, inputs, outputs and methods are the public API: see docs/public-api.md and Versioning and support. Angular 22 is supported (^22.0.0); newer majors are added to the range in a minor release.

Button

import { Component, signal } from '@angular/core';
import { GrButton } from '@grass-ds/angular';

@Component({
  selector: 'app-buttons',
  imports: [GrButton],
  template: `
    <button grButton>Save</button>
    <button grButton variant="secondary">Cancel</button>
    <button grButton variant="danger" [disabled]="busy()">Delete</button>
    <a grButton href="/docs">Docs</a>
  `,
})
export class Buttons {
  readonly busy = signal(false);
}

| Input | Type | Default | Notes | | ---------- | -------------------------------------- | ----------- | ------------------------------------------------------------------------- | | variant | 'primary' \| 'secondary' \| 'danger' | 'primary' | Rendered as data-variant. | | disabled | boolean | false | Native disabled on <button>; aria-disabled="true" on <a> (below). |

  • type: <button grButton> defaults to type="button" (as in the Reference), so it does not submit a form by accident. Write type="submit" or type="reset" statically to override it.
  • Disabled links: a disabled <a grButton> keeps its href but gets aria-disabled="true" and stays focusable. Click, Ctrl-click, middle-click and Enter are cancelled before any (click) handler runs, so handlers never fire while it is disabled.
  • Use the disabled input. An aria-disabled or type written statically in the template is kept, but do not bind [attr.aria-disabled] or [attr.type] on the same element: the component owns those attributes.

Card

The Card is a composite, so it is an element, <gr-card>, with its parts as attribute directives on elements you choose. It needs no @angular/forms and comes from the package root:

import { Component } from '@angular/core';
import { GR_CARD, GrButton } from '@grass-ds/angular';

@Component({
  selector: 'app-plan',
  imports: [GR_CARD, GrButton],
  template: `
    <gr-card variant="elevated" role="article" aria-labelledby="plan-title">
      <div grCardHeader>
        <h3 grCardTitle id="plan-title">Team plan</h3>
        <div grCardActions><button grButton variant="secondary">Edit</button></div>
      </div>
      <div grCardBody>...</div>
      <div grCardFooter><button grButton>Choose plan</button></div>
    </gr-card>
  `,
})
export class Plan {}

GR_CARD bundles GrCard, GrCardHeader, GrCardTitle, GrCardActions, GrCardMedia, GrCardBody and GrCardFooter; import them individually if you prefer. Each part directive only adds its gr-card__* class (<div grCardMedia><img alt="" /></div> for media), and <gr-card> renders no wrapper, so the output is the markup of the @grass-ds/css Card contract, pixel for pixel.

| Input | Type | Default | Notes | | --------- | -------------------------- | ------------ | --------------------------- | | variant | 'outlined' \| 'elevated' | 'outlined' | Rendered as data-variant. |

  • header and footer elements: a <header> or <footer> that is not inside an <article>, <section> (named) or role="article" is exposed as a banner or contentinfo landmark, so ten plain cards would add ten banners. Use <div grCardHeader> and <div grCardFooter>, as above, and use <header>/<footer> only when the card itself is an article, a section or has role="article".
  • Heading level: grCardTitle goes on whichever heading element fits your page (<h2>, <h3>...); the card never picks one.
  • Semantics: the <gr-card> host has no role. Add role="article" (or group/region) together with aria-labelledby pointing at the title, or leave it as a plain container. Attributes you write on the host are kept. For a list of cards, put each in an <li>.
  • Not interactive as a whole: do not make the card a link or button, and do not nest interactive cards; put actions in the header or footer. Not included: clickable or selectable cards, a horizontal layout.

Dialog

The Dialog is a composite, so it is an element, <gr-dialog>, with its parts as attribute directives on elements you choose. It wraps a native <dialog class="gr-dialog"> that it opens with showModal() (modal only), so the browser provides the top layer, the backdrop, the inert page behind it, Escape, and moving focus in and back to the element that opened it. It needs no @angular/forms and no CDK.

import { Component } from '@angular/core';
import { GR_DIALOG, GrButton } from '@grass-ds/angular';

@Component({
  selector: 'app-delete',
  imports: [GR_DIALOG, GrButton],
  template: `
    <button grButton (click)="confirm.showModal()">Delete project</button>

    <gr-dialog #confirm size="sm" (closed)="onClosed($event)">
      <div grDialogHeader>
        <h2 grDialogTitle>Delete project?</h2>
      </div>
      <div grDialogBody>This permanently deletes "Meadow". This cannot be undone.</div>
      <form method="dialog" grDialogFooter>
        <button grButton variant="secondary" type="submit" value="cancel" autofocus>Cancel</button>
        <button grButton variant="danger" type="submit" value="delete">Delete project</button>
      </form>
    </gr-dialog>
  `,
})
export class DeleteProject {
  onClosed(returnValue: string) {
    if (returnValue === 'delete') this.delete();
  }
  delete() {}
}

GR_DIALOG bundles GrDialog, GrDialogHeader, GrDialogTitle, GrDialogActions, GrDialogBody and GrDialogFooter.

| Input / output | Type | Default | Notes | | ----------------- | ---------------------- | ------- | --------------------------------------------------------------------------------------------------------- | | open | model<boolean> | false | Two-way: [(open)]="isOpen". Escape, a submitting form and close() write false back. | | size | 'sm' \| 'md' \| 'lg' | 'md' | Rendered as data-size. | | label | string | none | Accessible name for a dialog without a grDialogTitle. A title names the dialog by itself. | | closeOnBackdrop | boolean | false | Sets the native closedby="any"; where the browser lacks it, a click handler closes on a backdrop click. | | closed | output<string> | | Emits the returnValue on every close: the value of the submitting button, else ''. |

Methods, used through a template reference (#confirm) or viewChild: showModal() and close(returnValue?).

  • Name: grDialogTitle goes on the heading element that fits your page (<h2>, <h3>...); the dialog never picks a level. It gives the title an id (yours if you wrote a static one) and points aria-labelledby at it. Without a title, set label.
  • Return value: put grDialogFooter on a <form method="dialog"> (or wrap the body and the footer in one), and give its buttons type="submit" and a value: grButton defaults to type="button", which never submits. closed emits that value; it is reset to '' each time the dialog opens, so an Escape never reports the previous answer.
  • Content and @if: the content is created together with the parent view, also while the dialog is closed, so a component inside it is already running. To create it only when needed, wrap it in @if (isOpen()) (with [(open)]) or @if (confirm.open()); @if inside <gr-dialog> works as anywhere else.
  • Focus: the native behaviour is kept and nothing is added: initial focus goes to the first focusable element or the autofocus one (use autofocus on Cancel in a destructive confirmation), and closing returns focus to the opener. Open the dialog from a user action, never on page load.
  • Backdrop click and Escape: Escape always closes. closeOnBackdrop is opt-in and is never the only way out.
  • Not supported: role="alertdialog" and aria-describedby. Attributes written on <gr-dialog> land on the display: contents host, not on the native dialog, so they do nothing; a forwarding input can be added later. Name the dialog with grDialogTitle or label only.
  • Title in another component: grDialogTitle registers with the <gr-dialog> it is written inside. A title that lives in another component's template does not register: set label on the dialog instead. In dev mode an open dialog with neither a title nor a label logs a warning.
  • Scroll lock: while open, the page does not scroll (html:has(.gr-dialog:modal) { overflow: hidden } in the grass.components layer). Add scrollbar-gutter: stable to html to avoid the sideways shift when the page scrollbar disappears. An unlayered html { overflow: ... } rule in your own CSS beats the lock, because unlayered styles win over layers.
  • Enter in a form: pressing Enter in a field submits the form with its first submit button, so order the buttons accordingly, and put autofocus on the least destructive one (Cancel) in a destructive confirmation.
  • Tested in: Chromium in CI. Manual results for Firefox, Safari and screen readers are in the support table.
  • Rendered DOM: <gr-dialog> has display: contents and contains exactly the Reference markup, <dialog class="gr-dialog"> with your parts as direct children.
  • Not included: non-modal dialogs, drawers and side sheets, nested dialogs, an alertdialog variant, and a close icon (see the CSS README).

TextField

@angular/forms is an optional peer dependency: it is needed only by this entry point. GrButton comes from the package root, which never imports it, so a Button-only app does not need @angular/forms installed. Import the TextField from its own entry point:

import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { GrTextField } from '@grass-ds/angular/text-field';

@Component({
  selector: 'app-example',
  imports: [GrTextField, ReactiveFormsModule],
  template: `
    <div class="gr-field">
      <label class="gr-field__label" for="email">Email</label>
      <p class="gr-field__hint" id="email-hint">We only use it to sign you in.</p>
      <input
        grTextField
        id="email"
        type="email"
        [formControl]="email"
        [attr.aria-describedby]="showError() ? 'email-hint email-error' : 'email-hint'"
      />
      @if (showError()) {
        <p class="gr-field__error" id="email-error" role="alert">
          Enter an address like [email protected].
        </p>
      }
    </div>
  `,
})
export class Example {
  readonly email = new FormControl('', [Validators.required, Validators.email]);
  protected showError = () => this.email.touched && this.email.invalid;
}

<input grTextField> and <textarea grTextField> stay native elements, so formControl, formControlName, ngModel and Signal Forms ([formField]) work with no setup and no ControlValueAccessor of ours. The label, hint and error are plain markup from @grass-ds/css (see its README for the contract). The label is required. List the hint in aria-describedby, and add the error id only while the error is rendered.

| Input | Type | Default | Notes | | ----------- | --------- | ------- | -------------------------------------------------------------------- | | grInvalid | boolean | false | Sets aria-invalid="true". For errors the form does not know about. |

How aria-invalid is set. It is "true" when grInvalid is set, or when the form control the element is bound to is both invalid and touched, so a required field is not flagged before the user has reached it. That works for formControl, formControlName, ngModel and for Signal Forms [formField] (Angular exposes the field to the directive as an NgControl). So grInvalid is optional: use it for errors the form does not know about (a server message) or to show the error before the field is touched. form.markAllAsTouched() on a failed submit flags every field. disabled and readonly are the native attributes (use formControl.disable() for forms).

Why grInvalid and not invalid. [formField] writes the field's invalid() into any host input named invalid, touched or not, which would flag a pristine field on first render. grInvalid collides with nothing [formField] writes.

Do not bind [attr.aria-invalid] on the same element: the directive owns that attribute. An aria-invalid written statically in the template is kept.

Announcing errors. A screen reader announces an error that is inserted after the page loaded (on blur or submit) only if the element is a live region: give it role="alert", as above. An error that is already in the page when it loads (server-rendered) must not have role="alert", or it is announced on every load.

Checkbox

Like the TextField, the Checkbox lives in its own entry point because it imports @angular/forms:

import { Component, signal } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { GrCheckbox } from '@grass-ds/angular/checkbox';

@Component({
  selector: 'app-example',
  imports: [GrCheckbox, ReactiveFormsModule],
  template: `
    <div class="gr-field">
      <label class="gr-checkbox-label">
        <input type="checkbox" grCheckbox [formControl]="accepted" />
        <span>Accept the terms</span>
      </label>
    </div>
    <label class="gr-checkbox-label">
      <input type="checkbox" grCheckbox [(indeterminate)]="partial" />
      <span>Select all</span>
    </label>
  `,
})
export class Example {
  readonly accepted = new FormControl(false, Validators.requiredTrue);
  readonly partial = signal(true);
}

<input type="checkbox" grCheckbox> stays native, so formControl, formControlName, ngModel and [formField] work with no ControlValueAccessor. The markup contract (the wrapping .gr-checkbox-label, hint, error and the fieldset field group) is in the @grass-ds/css README.

| Input | Type | Default | Notes | | --------------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | indeterminate | boolean | false | Sets the DOM property (it has no attribute). The browser clears it when the user toggles; with [(indeterminate)] the cleared value is written back. | | grInvalid | boolean | false | Sets aria-invalid="true"; optional with forms, as for the TextField. |

Use [(indeterminate)] for select-all. A one-way [indeterminate]="x" cannot put the box back into the mixed state after the user cleared it, if the value you bind did not change (it is still true, so Angular sees nothing to write). The two-way form receives the cleared value, so the next true is a change. Select-all example:

import { Component, computed, signal } from '@angular/core';
import { GrCheckbox } from '@grass-ds/angular/checkbox';

@Component({
  selector: 'app-topics',
  imports: [GrCheckbox],
  template: `
    <label class="gr-checkbox-label">
      <input
        type="checkbox"
        grCheckbox
        [checked]="all()"
        [(indeterminate)]="partial"
        (change)="setAll($any($event.target).checked)"
      />
      <span>Select all</span>
    </label>
    @for (topic of topics(); track topic.name) {
      <label class="gr-checkbox-label">
        <input type="checkbox" grCheckbox [checked]="topic.on" (change)="toggle(topic.name)" />
        <span>{{ topic.name }}</span>
      </label>
    }
  `,
})
export class Topics {
  readonly topics = signal([
    { name: 'Design tokens', on: true },
    { name: 'Style layer', on: false },
  ]);
  readonly all = computed(() => this.topics().every((topic) => topic.on));
  readonly partial = signal(true);

  toggle(name: string) {
    this.topics.update((list) => list.map((t) => (t.name === name ? { ...t, on: !t.on } : t)));
    this.partial.set(!this.all() && this.topics().some((topic) => topic.on));
  }

  setAll(on: boolean) {
    this.topics.update((list) => list.map((topic) => ({ ...topic, on })));
    this.partial.set(false);
  }
}

aria-invalid follows the same rule as the TextField: "true" when grInvalid is set or the bound control is invalid and touched (reactive, template-driven and Signal Forms alike). Do not bind [attr.aria-invalid] on the element. Errors added after load need role="alert" (see TextField).

The components work without zone.js, use OnPush and ViewEncapsulation.None, and add no styles. Browse them in the Catalog.