Skip to content

Commit ac1be8d

Browse files
committed
docs: add blog feature with initial announcement post and routing
1 parent bfac064 commit ac1be8d

17 files changed

Lines changed: 663 additions & 8 deletions

public/blogs-manifest.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[
2+
{
3+
"slug": "announcement",
4+
"title": "Introducing Mat Expressive: Material 3 Expressive for Angular Material",
5+
"description": "Mat Expressive layers Material 3 Expressive — spring motion, shape morphing, and bold sizing — on top of Angular Material, without forking or replacing it.",
6+
"publishedOn": "23rd July, 2026",
7+
"order": 1,
8+
"author": {
9+
"name": "Dharmen",
10+
"xHandle": "shhdharmen",
11+
"avatar": "https://avatars.githubusercontent.com/u/6831283?v=4&size=64"
12+
},
13+
"readTime": 5,
14+
"coverImage": "/mat-exp-cover.png"
15+
}
16+
]

public/blogs/announcement.md

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
---
2+
title: "Introducing Mat Expressive: Material 3 Expressive for Angular Material"
3+
description: Mat Expressive layers Material 3 Expressive — spring motion, shape morphing, and bold sizing — on top of Angular Material, without forking or replacing it.
4+
publishedOn: 23rd July, 2026
5+
order: 1
6+
author:
7+
name: Dharmen
8+
xHandle: shhdharmen
9+
avatar: https://avatars.githubusercontent.com/u/6831283?v=4&size=64
10+
readTime: 5
11+
coverImage: /mat-exp-cover.png
12+
---
13+
14+
<img
15+
src="/mat-exp-cover.png"
16+
alt="Introducing Mat Expressive: Material 3 Expressive for Angular Material"
17+
class="w-full md:max-w-6xl! mx-auto rounded-lg" />
18+
19+
## What is Mat Expressive?
20+
21+
Mat Expressive is a library of styles, directives, and a few new components that sit **on top of** Angular Material. It's not a fork and not a replacement — you keep using `MatButton`, `MatFab`, and the rest of Angular Material as-is. Mat Expressive layers M3 Expressive behavior onto them.
22+
23+
Concretely, it gives you three things:
24+
25+
- **Styles** — SCSS mixins that restyle existing Angular Material components using Material's own [component token overrides](https://material.angular.dev/guide/theming#component-tokens) and CSS variables, so they match M3 Expressive.
26+
- **Directives** — for the cases styling alone can't reach (things like shape morphing on press, which need actual behavior, not just CSS).
27+
- **New components** — for patterns M3 Expressive defines that Angular Material doesn't have a component for at all (loading indicators, FAB menus, split buttons).
28+
29+
Under the hood, motion is powered by GSAP spring physics, every animation respects `prefers-reduced-motion`, and the library is SSR-safe.
30+
31+
## Installation
32+
33+
```bash
34+
ng add @ngm-dev/mat-exp
35+
```
36+
37+
The schematic wires up the package for you. From there, add the styles you need globally:
38+
39+
```scss
40+
// styles.scss
41+
@use '@ngm-dev/mat-exp' as mat-exp;
42+
43+
html {
44+
@include mat-exp.mat-exp-button-styles();
45+
}
46+
```
47+
48+
## A first component: the expressive button
49+
50+
The clearest way to see what Mat Expressive does is the button. Angular Material already has `matButton` — Mat Expressive adds size, shape, and toggle variations on top of it, plus the shape-morph-on-press behavior from the M3 Expressive spec.
51+
52+
You can opt in two ways.
53+
54+
**Option 1 — CSS class + data attributes**, no extra imports beyond styles:
55+
56+
```html
57+
<button matButton="elevated" class="mat-exp-button" data-size="xs" data-shape="square">
58+
Elevated
59+
</button>
60+
<button matButton="tonal" class="mat-exp-button" data-size="s">Tonal</button>
61+
```
62+
63+
**Option 2 — the `matExpButton` directive**, if you want type safety and two-way bindable inputs:
64+
65+
```ts
66+
import { MatButton } from '@angular/material/button';
67+
import { MatExpButton } from '@ngm-dev/mat-exp';
68+
69+
@Component({
70+
selector: 'app-root',
71+
imports: [MatButton, MatExpButton],
72+
template: `
73+
<button matButton="elevated" size="xs" shape="square" matExpButton>Elevated</button>
74+
<button matButton="tonal" size="s" matExpButton>Tonal</button>
75+
`,
76+
})
77+
export class App {}
78+
```
79+
80+
Both approaches give you the same variations:
81+
82+
- **Size**: `xs`, `s`, `m`, `l`, `xl`
83+
- **Shape**: `round`, `square`
84+
- **Toggle**: `selected`, `unselected`
85+
- **State**: `pressed` (driven by the `:active` pseudo-selector)
86+
87+
## Shape morphing, and why it's a directive and not just CSS
88+
89+
One of the defining traits of M3 Expressive buttons is that they morph shape when pressed — round buttons square off slightly, and both round and square buttons converge on the same pressed shape. Toggle buttons go further: the *resting* shape itself changes between selected and unselected states.
90+
91+
This is exactly the kind of thing that can't be pure CSS — it needs to read component state and react to it — which is why `matExpButton` exists as a directive rather than just a stylesheet.
92+
93+
Accessibility is handled automatically here too: the shape-morph transition stops entirely under `prefers-reduced-motion: reduce`, because `matExpButton` piggybacks on Angular Material's own `matButton` host, which already detects the setting.
94+
95+
## Toggle state: a deliberate design choice
96+
97+
If you've used a toggle button before, you might expect `toggle` to flip on click automatically. In Mat Expressive, it doesn't — unless the button lives inside a `<mat-exp-button-group>`.
98+
99+
For a standalone toggle button, the library leaves the click-to-state transition up to you:
100+
101+
```ts
102+
import { signal } from '@angular/core';
103+
import { MatButton } from '@angular/material/button';
104+
import { MatExpButton } from '@ngm-dev/mat-exp';
105+
106+
@Component({
107+
selector: 'app-root',
108+
imports: [MatButton, MatExpButton],
109+
template: `
110+
<button
111+
matButton="tonal"
112+
matExpButton
113+
[(toggle)]="favorited"
114+
(click)="favorited.set(favorited() === 'selected' ? 'unselected' : 'selected')"
115+
>
116+
Favorite
117+
</button>
118+
`,
119+
})
120+
export class App {
121+
protected readonly favorited = model<'selected' | 'unselected'>('unselected');
122+
}
123+
```
124+
125+
This is intentional rather than an oversight: a lone button only has one consumer for its click event, so guessing what that click should mean would be presumptuous. Inside a `MatExpButtonGroup`, the group already owns selection state for its buttons — adding your own click handler there would fight it.
126+
127+
## Cutting the CSS payload
128+
129+
By default, the style mixins emit CSS for every size × shape × state × toggle combination. If your app only ever uses two or three sizes, you don't need to ship the rest:
130+
131+
```scss
132+
@use '@ngm-dev/mat-exp' as mat-exp;
133+
134+
html {
135+
@include mat-exp.mat-exp-button-styles(
136+
(
137+
sizes: ('s', 'm'),
138+
)
139+
);
140+
}
141+
```
142+
143+
There's also a `skip-html-element-styles` option if you don't want Mat Expressive touching the underlying Angular Material DOM elements at all — useful if you're worried about coupling to Angular Material's internal classes, at the cost of losing icon-size adjustments and shape morphing.
144+
145+
## What's included so far
146+
147+
At launch, Mat Expressive covers:
148+
149+
- **Buttons**[Button](docs/components/all-buttons/button), [Icon Button](docs/components/all-buttons/icon-button), [Button Group](docs/components/all-buttons/button-group), [Split Button](docs/components/all-buttons/split-button), [FAB Menu](docs/components/all-buttons/fab-menu)
150+
- **Loading & Progress** — an M3 Expressive [loading indicator](/docs/components/loading-and-progress/loading-indicator) with GSAP spring motion
151+
152+
More components are planned, following the same pattern: style what Angular Material already has, build what it doesn't.
153+
154+
## Try it
155+
156+
```bash
157+
ng add @ngm-dev/mat-exp
158+
```
159+
160+
- 📖 Docs: [expressive.angular-material.dev](https://expressive.angular-material.dev/)
161+
- 💻 GitHub: [github.com/Angular-Material-Dev/mat-exp](https://github.com/Angular-Material-Dev/mat-exp)
162+
- 🐦 Follow along: [@ngMaterialDev](https://x.com/ngMaterialDev)
163+
164+
It's MIT licensed and free. If you're building on Angular Material and want the M3 Expressive look — motion, shape morphing, and all — without hand-rolling it yourself, give it a try. Issues and feature requests are welcome on GitHub.

public/nav-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"label": "What is Mat Expressive?",
1313
"path": "/docs/getting-started/what-is-mat-expressive",
1414
"order": 1,
15-
"description": "An introduction to Mat Expressive — a collection of components, directives, and styles for Angular Material aligned with the Material Design 3 Expressive Design System."
15+
"description": "An introduction to Mat Expressive — a collection of components, directives, and styles for Angular Material aligned with the Material 3 Expressive Design System."
1616
},
1717
{
1818
"label": "Installation",
@@ -149,7 +149,7 @@
149149
"label": "What is Mat Expressive?",
150150
"path": "/docs/getting-started/what-is-mat-expressive",
151151
"order": 1,
152-
"description": "An introduction to Mat Expressive — a collection of components, directives, and styles for Angular Material aligned with the Material Design 3 Expressive Design System."
152+
"description": "An introduction to Mat Expressive — a collection of components, directives, and styles for Angular Material aligned with the Material 3 Expressive Design System."
153153
},
154154
{
155155
"label": "Installation",

public/playground-schemas.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
},
6060
{
6161
"filename": "button-group-preview.component.html",
62-
"content": "<div class=\"preview-container\">\n <mat-exp-button-group\n [size]=\"size()\"\n [shape]=\"shape()\"\n [variant]=\"variant()\"\n [appearance]=\"appearance()\"\n [selection]=\"selection()\"\n [disabled]=\"disabled()\"\n [disableBounce]=\"disableBounce()\"\n >\n <button matIconButton matExpIconButton aria-label=\"Delete\">\n <mat-icon>delete</mat-icon>\n </button>\n <button matButton matExpButton>Label</button>\n <button matButton matExpButton>\n <mat-icon>edit</mat-icon>\n Label\n </button>\n <button matIconButton matExpIconButton aria-label=\"Favorite\">\n <mat-icon>favorite</mat-icon>\n </button>\n </mat-exp-button-group>\n <mat-exp-button-group\n [size]=\"size()\"\n [shape]=\"shape()\"\n [variant]=\"variant()\"\n [appearance]=\"appearance()\"\n [selection]=\"selection()\"\n [disabled]=\"disabled()\"\n [disableBounce]=\"disableBounce()\"\n >\n <button matButton matExpButton>Label</button>\n <button matButton matExpButton>Label</button>\n <button matButton matExpButton>\n <mat-icon>edit</mat-icon>\n Label\n </button>\n <button matButton matExpButton>\n <mat-icon>edit</mat-icon>\n Label\n </button>\n </mat-exp-button-group>\n <div>\n <h3>Usage with reactive form</h3>\n <mat-exp-button-group\n [size]=\"size()\"\n [shape]=\"shape()\"\n [variant]=\"variant()\"\n [appearance]=\"appearance()\"\n [selection]=\"selection()\"\n [disabled]=\"disabled()\"\n [disableBounce]=\"disableBounce()\"\n [formControl]=\"control\"\n >\n <button matIconButton matExpIconButton value=\"delete\" aria-label=\"Delete\">\n <mat-icon>delete</mat-icon>\n </button>\n <button matIconButton matExpIconButton value=\"edit\" aria-label=\"Edit\">\n <mat-icon>edit</mat-icon>\n </button>\n <button matIconButton matExpIconButton value=\"star\" aria-label=\"Star\">\n <mat-icon>star</mat-icon>\n </button>\n <button matIconButton matExpIconButton value=\"favorite\" aria-label=\"Favorite\">\n <mat-icon>favorite</mat-icon>\n </button>\n </mat-exp-button-group>\n\n <pre>\n <code>\n {{ control.value | json }}\n </code>\n </pre>\n </div>\n</div>\n",
62+
"content": "<div class=\"preview-container\">\n <mat-exp-button-group\n [size]=\"size()\"\n [shape]=\"shape()\"\n [variant]=\"variant()\"\n [appearance]=\"appearance()\"\n [selection]=\"selection()\"\n [disabled]=\"disabled()\"\n [disableBounce]=\"disableBounce()\"\n >\n <button matIconButton matExpIconButton aria-label=\"Delete\">\n <mat-icon>delete</mat-icon>\n </button>\n <button matButton matExpButton>Label</button>\n <button matButton matExpButton>\n <mat-icon>edit</mat-icon>\n Label\n </button>\n <button matIconButton matExpIconButton aria-label=\"Favorite\">\n <mat-icon>favorite</mat-icon>\n </button>\n </mat-exp-button-group>\n <mat-exp-button-group\n [size]=\"size()\"\n [shape]=\"shape()\"\n [variant]=\"variant()\"\n [appearance]=\"appearance()\"\n [selection]=\"selection()\"\n [disabled]=\"disabled()\"\n [disableBounce]=\"disableBounce()\"\n >\n <button matButton matExpButton>Label</button>\n <button matButton matExpButton>Label</button>\n <button matButton matExpButton>\n <mat-icon>edit</mat-icon>\n Label\n </button>\n <button matButton matExpButton>\n <mat-icon>edit</mat-icon>\n Label\n </button>\n </mat-exp-button-group>\n <div>\n <h3>Usage with reactive form</h3>\n <mat-exp-button-group\n [size]=\"size()\"\n [shape]=\"shape()\"\n [variant]=\"variant()\"\n [appearance]=\"appearance()\"\n [selection]=\"selection()\"\n [disabled]=\"disabled()\"\n [disableBounce]=\"disableBounce()\"\n [formControl]=\"control\"\n >\n <button matIconButton matExpIconButton value=\"delete\" aria-label=\"Delete\">\n <mat-icon>delete</mat-icon>\n </button>\n <button matIconButton matExpIconButton value=\"edit\" aria-label=\"Edit\">\n <mat-icon>edit</mat-icon>\n </button>\n <button matIconButton matExpIconButton value=\"star\" aria-label=\"Star\">\n <mat-icon>star</mat-icon>\n </button>\n <button matIconButton matExpIconButton value=\"favorite\" aria-label=\"Favorite\">\n <mat-icon>favorite</mat-icon>\n </button>\n </mat-exp-button-group>\n\n <pre>\n <code>\n Form value: {{ control.value | json }}\n </code>\n </pre>\n </div>\n</div>\n",
6363
"lang": "html"
6464
},
6565
{

public/routes.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
/docs/api
2020
/
2121
/sponsor
22+
/blogs
23+
/blogs/announcement
2224
/docs/api/mat-exp/interfaces/MatExpSelectableButton
2325
/docs/api/mat-exp/classes/MatExpSelectableButtonChange
2426
/docs/api/mat-exp/directives/MatExpButton

scripts/build-blogs.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Build script: scans public/blogs/, generates public/blogs-manifest.json.
3+
*
4+
* Usage: tsx scripts/build-blogs.ts
5+
*
6+
* Also imported by build-docs.ts, which appends `/blogs` + `/blogs/:slug`
7+
* routes to routes.txt from the manifest this writes — one .md file per
8+
* post, no subdirectories (unlike public/docs/).
9+
*
10+
* Frontmatter is validated against KNOWN_BLOG_FRONTMATTER_KEYS: title,
11+
* description, publishedOn, order, author, readTime, coverImage. An
12+
* unrecognized key throws and fails the build, same convention as
13+
* build-docs.ts's KNOWN_FRONTMATTER_KEYS.
14+
*
15+
* `order` sorts the index listing newest-first (higher number = more
16+
* recent); posts without an `order` sort last.
17+
*/
18+
19+
import * as fs from 'node:fs';
20+
import * as path from 'node:path';
21+
import { pathToFileURL } from 'node:url';
22+
import matter from 'gray-matter';
23+
24+
const BLOGS_ROOT = path.resolve(process.cwd(), 'public/blogs');
25+
const MANIFEST_OUT = path.resolve(process.cwd(), 'public/blogs-manifest.json');
26+
27+
export interface BlogAuthor {
28+
name: string;
29+
xHandle?: string;
30+
avatar?: string;
31+
}
32+
33+
export interface BlogPost {
34+
slug: string;
35+
title: string;
36+
description?: string;
37+
publishedOn: string;
38+
order?: number;
39+
author: BlogAuthor;
40+
readTime?: number;
41+
coverImage?: string;
42+
}
43+
44+
const KNOWN_BLOG_FRONTMATTER_KEYS = new Set([
45+
'title',
46+
'description',
47+
'publishedOn',
48+
'order',
49+
'author',
50+
'readTime',
51+
'coverImage',
52+
]);
53+
54+
function validateFrontmatterKeys(filePath: string, fm: Record<string, unknown>): void {
55+
const unknown = Object.keys(fm).filter((key) => !KNOWN_BLOG_FRONTMATTER_KEYS.has(key));
56+
if (unknown.length === 0) return;
57+
const rel = path.relative(process.cwd(), filePath);
58+
throw new Error(
59+
`Invalid frontmatter in ${rel}: unknown key(s) ${unknown.map((k) => `"${k}"`).join(', ')}.\n` +
60+
`Known keys: ${[...KNOWN_BLOG_FRONTMATTER_KEYS].sort().join(', ')}.`,
61+
);
62+
}
63+
64+
function requireString(fm: Record<string, unknown>, key: string, filePath: string): string {
65+
const value = fm[key];
66+
if (typeof value !== 'string' || value.length === 0) {
67+
const rel = path.relative(process.cwd(), filePath);
68+
throw new Error(`Missing required "${key}" frontmatter in ${rel}`);
69+
}
70+
return value;
71+
}
72+
73+
function readPost(fileName: string): BlogPost {
74+
const filePath = path.join(BLOGS_ROOT, fileName);
75+
const { data: fm } = matter(fs.readFileSync(filePath, 'utf-8'));
76+
validateFrontmatterKeys(filePath, fm);
77+
78+
const title = requireString(fm, 'title', filePath);
79+
const publishedOn = requireString(fm, 'publishedOn', filePath);
80+
81+
const author = fm['author'] as Partial<BlogAuthor> | undefined;
82+
if (!author || typeof author.name !== 'string' || author.name.length === 0) {
83+
throw new Error(
84+
`Missing required "author.name" frontmatter in ${path.relative(process.cwd(), filePath)}`,
85+
);
86+
}
87+
88+
return {
89+
slug: fileName.replace(/\.md$/, ''),
90+
title,
91+
description: typeof fm['description'] === 'string' ? fm['description'] : undefined,
92+
publishedOn,
93+
order: typeof fm['order'] === 'number' ? fm['order'] : undefined,
94+
author: {
95+
name: author.name,
96+
xHandle: typeof author.xHandle === 'string' ? author.xHandle : undefined,
97+
avatar: typeof author.avatar === 'string' ? author.avatar : undefined,
98+
},
99+
readTime: typeof fm['readTime'] === 'number' ? fm['readTime'] : undefined,
100+
coverImage: typeof fm['coverImage'] === 'string' ? fm['coverImage'] : undefined,
101+
};
102+
}
103+
104+
/** Scans public/blogs/*.md and returns the post list, newest (`order`) first. */
105+
export function buildBlogsManifest(): BlogPost[] {
106+
if (!fs.existsSync(BLOGS_ROOT)) return [];
107+
108+
const posts = fs
109+
.readdirSync(BLOGS_ROOT)
110+
.filter((name) => name.endsWith('.md'))
111+
.map(readPost);
112+
113+
posts.sort((a, b) => (b.order ?? -Infinity) - (a.order ?? -Infinity));
114+
115+
return posts;
116+
}
117+
118+
/** Builds and writes public/blogs-manifest.json; returns the posts written. */
119+
export function writeBlogsManifest(): BlogPost[] {
120+
const posts = buildBlogsManifest();
121+
fs.writeFileSync(MANIFEST_OUT, JSON.stringify(posts, null, 2) + '\n', 'utf-8');
122+
console.log(`✓ Written ${MANIFEST_OUT} (${posts.length} posts)`);
123+
return posts;
124+
}
125+
126+
const isDirectRun = import.meta.url === pathToFileURL(process.argv[1] ?? '').href;
127+
if (isDirectRun) {
128+
writeBlogsManifest();
129+
}

scripts/build-docs.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import * as path from 'node:path';
3434
import { pathToFileURL } from 'node:url';
3535
import matter from 'gray-matter';
3636
import { runMetadataExtraction } from './extract-metadata';
37+
import { writeBlogsManifest } from './build-blogs';
3738

3839
// ---------------------------------------------------------------------------
3940
// Types
@@ -428,6 +429,13 @@ async function main(): Promise<void> {
428429
// Standalone root routes (no /docs prefix — served by StandaloneShellComponent)
429430
routeLines.push('/');
430431
routeLines.push('/sponsor');
432+
433+
// Blogs manifest + routes (also standalone, no /docs prefix)
434+
const blogPosts = writeBlogsManifest();
435+
routeLines.push('/blogs');
436+
for (const post of blogPosts) {
437+
routeLines.push(`/blogs/${post.slug}`);
438+
}
431439
if (fs.existsSync(API_MANIFEST_OUT)) {
432440
const apiManifest = JSON.parse(fs.readFileSync(API_MANIFEST_OUT, 'utf-8')) as Record<
433441
string,

src/app/app.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { provideNgxMetaJsonLd } from '@davidlj95/ngx-meta/json-ld';
2626
import { routes } from './app.routes';
2727
import { MAT_ICON_DEFAULT_OPTIONS } from '@angular/material/icon';
2828
import { environment } from '../environments/environment';
29-
import { SITE_NAME } from './shared/utils/json-ld';
29+
import { SITE_NAME, absoluteUrl } from './shared/utils/json-ld';
3030
import { CustomElementsService } from './shared/services/custom-elements.service';
3131

3232
export const appConfig: ApplicationConfig = {
@@ -46,7 +46,7 @@ export const appConfig: ApplicationConfig = {
4646
applicationName: SITE_NAME,
4747
canonicalUrl: ANGULAR_ROUTER_URL,
4848
locale: 'en',
49-
image: { url: '/mat-exp-cover.png', alt: SITE_NAME },
49+
image: { url: absoluteUrl('/mat-exp-cover.png'), alt: SITE_NAME },
5050
standard: {
5151
generator: true,
5252
author: 'Angular Material Dev',

0 commit comments

Comments
 (0)