diff --git a/packages/site_shared/lib/_sass/base/_reset.scss b/packages/site_shared/lib/_sass/base/_reset.scss
index 61d4b5cdd8..a4e30a08c6 100644
--- a/packages/site_shared/lib/_sass/base/_reset.scss
+++ b/packages/site_shared/lib/_sass/base/_reset.scss
@@ -15,6 +15,7 @@ body {
button,
input,
+optgroup,
select,
textarea {
font-family: inherit;
@@ -27,4 +28,4 @@ textarea {
color: inherit;
margin: 0;
padding: 0;
-}
+}
\ No newline at end of file
diff --git a/packages/site_shared/lib/_sass/components/_blog.scss b/packages/site_shared/lib/_sass/components/_blog.scss
new file mode 100644
index 0000000000..33ffff8982
--- /dev/null
+++ b/packages/site_shared/lib/_sass/components/_blog.scss
@@ -0,0 +1,501 @@
+.blog-index {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+ width: 100%;
+ max-width: 1400px;
+ margin: 0 auto;
+ box-sizing: border-box;
+}
+
+.blog-index-toolbar {
+ display: flex;
+ align-items: stretch;
+ gap: 1rem;
+ border-bottom: 1px solid var(--site-outline);
+ margin-bottom: 2rem;
+ font-family: var(--site-ui-fontFamily);
+
+ .blog-feed-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ align-self: center;
+ padding: 0.25rem;
+ white-space: nowrap;
+ color: var(--site-base-fgColor-alt);
+ text-decoration: none;
+ font-weight: 500;
+
+ &:hover {
+ color: var(--site-base-fgColor);
+ text-decoration: none;
+ }
+
+ .material-symbols {
+ font-size: 1.25rem;
+ }
+
+ .blog-feed-link-label {
+ display: none;
+
+ @media (min-width: 576px) {
+ display: block;
+ }
+ }
+ }
+}
+
+.blog-categories {
+ display: flex;
+ justify-content: flex-start;
+ flex: 1;
+ min-width: 0;
+ gap: 1rem;
+ overflow-x: auto;
+ overflow-y: hidden;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ .category-separator {
+ align-self: center;
+ min-width: 1px;
+ height: 1.25rem;
+ background-color: var(--site-outline);
+ margin: 0 -0.5rem;
+ pointer-events: none;
+ user-select: none;
+ }
+}
+
+.blog-category-chip {
+ border: none;
+ border-bottom: 2px solid transparent;
+ cursor: pointer;
+ transition: color 0.2s ease, border-color 0.2s ease;
+ white-space: nowrap;
+ margin-bottom: -1px;
+ padding: 2px;
+
+ &:hover button {
+ color: var(--site-base-fgColor);
+ background-color: transparent;
+ }
+
+ &.active {
+ border-bottom-color: var(--site-base-fgColor);
+
+ button {
+ background-color: transparent;
+ color: var(--site-base-fgColor);
+ }
+ }
+
+ button {
+ padding: 0.75rem 0.5rem;
+
+ font-size: 1rem;
+ font-weight: 500;
+ background-color: transparent;
+ color: var(--site-base-fgColor-alt);
+ }
+}
+
+.blog-posts-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 2rem;
+
+ @media (max-width: 768px) {
+ grid-template-columns: 1fr;
+ }
+
+ .blog-card:not(.layout-featured):not(.layout-grid):not(.layout-list) {
+ display: none;
+ }
+}
+
+.blog-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ background-color: var(--site-bg-color, transparent);
+ padding: 1.5rem 0;
+ border-bottom: 1px solid var(--site-outline);
+ overflow: hidden;
+
+ &,
+ &:hover,
+ &:active,
+ &:focus {
+ text-decoration: none;
+ color: inherit;
+ }
+
+ .blog-card-image {
+ width: 100%;
+ aspect-ratio: 16/9;
+ overflow: hidden;
+ border-radius: 8px;
+
+ img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ .blog-card-title {
+ font-weight: 800;
+ font-size: 1.5rem;
+ line-height: 1.2;
+ margin-top: 0.25rem;
+ margin-bottom: 0.5rem;
+ color: var(--site-base-fgColor);
+
+ a {
+ color: inherit;
+ text-decoration: none;
+
+ &:hover {
+ color: var(--site-primary-color);
+ }
+ }
+ }
+
+ .blog-card-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .blog-card-spacer {
+ flex: 1;
+ }
+
+ .blog-card-description {
+ font-size: 1rem;
+ color: var(--site-base-fgColor-alt);
+ margin-bottom: 1rem;
+ line-height: 1.5;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ &.layout-featured {
+ grid-column: 1 / -1;
+
+ @media (min-width: 900px) {
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 3rem;
+ padding-bottom: 2rem;
+
+ .blog-card-image {
+ flex: 1.5;
+ border-radius: 4px;
+ }
+
+ .blog-card-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ }
+
+ .blog-card-title {
+ font-size: 2.25rem;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ margin-top: 0;
+ margin-bottom: 0.75rem;
+ }
+
+ .blog-card-description {
+ font-size: 1.125rem;
+ -webkit-line-clamp: 4;
+ line-clamp: 4;
+ }
+ }
+ }
+
+ &.layout-grid {
+ @media (min-width: 768px) {
+ height: 100%;
+ border: 1px solid var(--site-outline);
+ border-radius: 12px;
+ padding: 1rem;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+
+ &:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.1);
+ border-color: var(--site-primary-color);
+ }
+
+ .blog-card-image {
+ margin-bottom: 1rem;
+ aspect-ratio: 16/10;
+ }
+
+ .blog-card-title {
+ font-size: 1.35rem;
+ }
+
+ .blog-card-description {
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ }
+ }
+ }
+
+ &.layout-list {
+ grid-column: 1 / -1;
+
+ @media (min-width: 768px) {
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 2rem;
+
+ .blog-card-image {
+ width: 250px;
+ flex-shrink: 0;
+ }
+ }
+ }
+}
+
+.blog-card-meta {
+ font-size: 0.875rem;
+ color: var(--site-base-fgColor-alt);
+ display: flex;
+ align-items: center;
+ gap: 0.25rem 0.75rem;
+ margin-top: auto;
+ flex-wrap: wrap;
+
+ .blog-card-authors {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ }
+
+ .blog-card-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ object-fit: cover;
+ background-color: var(--site-outline);
+
+ &:not(:first-child) {
+ margin-left: -18px;
+ border: 1px solid var(--site-base-bgColor);
+ box-sizing: content-box;
+ }
+ }
+
+ .author {
+ font-weight: var(--site-fontWeight-bold);
+ color: var(--site-base-fgColor);
+ text-decoration: none;
+
+ .author-name {
+ white-space: nowrap;
+ }
+
+ .author-separator {
+ white-space: normal;
+ }
+ }
+}
+
+.post-info {
+ display: flex;
+ align-items: start;
+ gap: 0.25rem;
+ padding: 1rem 0;
+ margin: 2rem 0;
+ color: var(--site-base-fgColor);
+
+ .post-info-main {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.75rem;
+
+ @media (max-width: 540px) {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+ }
+
+ >a {
+ text-decoration: none;
+ color: inherit;
+ display: block;
+
+ &:hover .post-info-name {
+ color: var(--site-primary-color);
+ }
+ }
+
+ .post-info-author {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-right: 0.25rem;
+
+
+ img {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ object-fit: cover;
+ display: block;
+
+ &:not(:first-child) {
+ margin-left: -28px;
+ border: 1px solid var(--site-base-bgColor);
+ box-sizing: content-box;
+ }
+ }
+ }
+
+ .post-info-name {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: var(--site-fontWeight-bold);
+ line-height: 1.2;
+
+ a,
+ span {
+ color: var(--site-base-fgColor);
+ font-weight: var(--site-fontWeight-bold);
+ }
+
+ .post-info-author-name {
+ white-space: nowrap;
+ }
+
+ .post-info-author-separator {
+ white-space: normal;
+ }
+ }
+
+ .post-info-meta {
+ font-size: 0.95rem;
+ color: var(--site-base-fgColor-alt);
+ font-weight: var(--site-fontWeight-normal);
+ white-space: nowrap;
+
+ @media (max-width: 540px) {
+ &::before {
+ content: '';
+ margin: 0;
+ }
+ }
+ }
+
+ .post-info-actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ }
+
+ .dropdown {
+ position: relative;
+
+ .dropdown-content {
+ right: 0;
+ top: 100%;
+ min-width: 180px;
+
+ background-color: var(--site-base-bgColor);
+ color: var(--site-base-fgColor);
+ border: var(--site-outline) 1px solid;
+ box-shadow: none;
+
+ font-family: var(--site-ui-fontFamily);
+ font-size: 1rem;
+
+ a,
+ button {
+ color: var(--site-base-fgColor);
+
+ &:hover {
+ color: var(--site-primary-color);
+ }
+ }
+ }
+
+ .dropdown-menu {
+
+ svg,
+ .material-symbols {
+ width: 20px;
+ height: 20px;
+ font-size: 20px;
+ fill: currentColor;
+ }
+ }
+ }
+
+
+ @media (max-width: 600px) {
+ gap: 0.5rem;
+ }
+}
+
+.blog-subtitle {
+ font-size: 1.5rem;
+ line-height: 1.4;
+ color: var(--site-base-fgColor-alt);
+ margin-top: 0;
+ margin-bottom: 2rem;
+ font-weight: var(--site-fontWeight-normal);
+}
+
+.post-content {
+ font-family: var(--site-blog-fontFamily);
+ line-height: 1.7em;
+ font-size: 1.2rem;
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-family: var(--site-ui-fontFamily);
+ font-weight: var(--site-fontWeight-bold);
+ }
+
+ .postbreak span {
+ background-color: var(--site-base-fgColor);
+ }
+}
+
+.blog-next-posts {
+ margin: 4rem 0;
+ padding-top: 4rem;
+ border-top: 1px solid var(--site-outline);
+
+ h2 {
+ font-size: 2rem;
+ margin-top: 0;
+ margin-bottom: 2rem;
+ }
+
+ .blog-posts-grid {
+ margin-top: 2rem;
+ }
+}
\ No newline at end of file
diff --git a/packages/site_shared/lib/assets/social/bluesky.svg b/packages/site_shared/lib/assets/social/bluesky.svg
new file mode 100644
index 0000000000..f14d84b6a5
--- /dev/null
+++ b/packages/site_shared/lib/assets/social/bluesky.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/site_shared/lib/assets/social/github.svg b/packages/site_shared/lib/assets/social/github.svg
new file mode 100644
index 0000000000..405d8a4604
--- /dev/null
+++ b/packages/site_shared/lib/assets/social/github.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/packages/site_shared/lib/assets/social/linkedin.svg b/packages/site_shared/lib/assets/social/linkedin.svg
new file mode 100644
index 0000000000..8c77b11d18
--- /dev/null
+++ b/packages/site_shared/lib/assets/social/linkedin.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/packages/site_shared/lib/assets/social/medium.svg b/packages/site_shared/lib/assets/social/medium.svg
new file mode 100755
index 0000000000..33b6b60754
--- /dev/null
+++ b/packages/site_shared/lib/assets/social/medium.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/packages/site_shared/lib/assets/social/x.svg b/packages/site_shared/lib/assets/social/x.svg
new file mode 100644
index 0000000000..b6b96226a8
--- /dev/null
+++ b/packages/site_shared/lib/assets/social/x.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/site_shared/lib/blog.dart b/packages/site_shared/lib/blog.dart
new file mode 100644
index 0000000000..2e5def88d2
--- /dev/null
+++ b/packages/site_shared/lib/blog.dart
@@ -0,0 +1,6 @@
+// Copyright 2026 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+export 'src/blog/atom_feed.dart';
+export 'src/blog/models.dart';
diff --git a/packages/site_shared/lib/components/blog/blog_card.dart b/packages/site_shared/lib/components/blog/blog_card.dart
new file mode 100644
index 0000000000..48a4cd837f
--- /dev/null
+++ b/packages/site_shared/lib/components/blog/blog_card.dart
@@ -0,0 +1,116 @@
+// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+
+import '../../blog.dart';
+import '../../util.dart';
+
+class BlogCard extends StatelessComponent {
+ const BlogCard({
+ required this.post,
+ required this.url,
+ this.className,
+ this.priority = BlogCardPriority.normal,
+ super.key,
+ });
+
+ final Post post;
+ final String url;
+ final String? className;
+ final BlogCardPriority priority;
+
+ @override
+ Component build(BuildContext context) {
+ final authors = context.page.authorsByIds(post.authorIds);
+ return a(
+ href: url,
+ classes: ['blog-card', ?className].toClasses,
+ attributes: {'data-category': post.category ?? 'other'},
+ [
+ if (post.image case final postImage?)
+ div(classes: 'blog-card-image', [
+ img(
+ src: postImage,
+ alt: post.title,
+ loading: switch (priority) {
+ .featured => MediaLoading.eager,
+ .high => null,
+ .normal => MediaLoading.lazy,
+ },
+ attributes: {
+ if (priority == .featured)
+ 'fetchpriority': 'high'
+ else if (priority == .normal)
+ 'decoding': 'async',
+ },
+ ),
+ ]),
+ div(classes: 'blog-card-content', [
+ h3(classes: 'blog-card-title', [
+ .text(post.title),
+ ]),
+ p(classes: 'blog-card-description', [
+ .text(post.description),
+ ]),
+ const span(classes: 'blog-card-spacer', []),
+ div(classes: 'blog-card-meta', [
+ div(classes: 'blog-card-authors', [
+ for (final author in authors)
+ if (author.image case final authorImage?
+ when authorImage.isNotEmpty)
+ img(
+ src: context.resolveAsset('/blog/authors/$authorImage'),
+ alt: author.name,
+ classes: 'blog-card-avatar',
+ )
+ else if (author.github?.avatarUrl case final avatarUrl?)
+ img(
+ src: avatarUrl,
+ alt: author.name,
+ classes: 'blog-card-avatar',
+ ),
+ span(classes: 'author', [
+ for (final (index, author) in authors.indexed) ...[
+ span(classes: 'author-name', [
+ .text(author.name),
+ ]),
+ if (index < authors.length - 1)
+ const span(classes: 'author-separator', [.text(', ')]),
+ ],
+ ]),
+ ]),
+ div([
+ span(classes: 'date', [
+ .text(post.formattedDate),
+ ]),
+ const span(classes: 'separator', [.text(' · ')]),
+ span(classes: 'reading-time', [
+ .text(post.readingTime),
+ ]),
+ ]),
+ ]),
+ ]),
+ ],
+ );
+ }
+}
+
+/// Priority levels for blog cards.
+///
+/// Used to configure loading behavior of their content, particularly images.
+enum BlogCardPriority {
+ /// A featured blog card.
+ featured,
+
+ /// A high-priority blog card.
+ ///
+ /// Often the first 5 or so cards.
+ high,
+
+ /// A normal-priority blog card.
+ normal,
+}
diff --git a/packages/site_shared/lib/components/blog/blog_index.dart b/packages/site_shared/lib/components/blog/blog_index.dart
new file mode 100644
index 0000000000..fd8cc84b1d
--- /dev/null
+++ b/packages/site_shared/lib/components/blog/blog_index.dart
@@ -0,0 +1,77 @@
+// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:collection/collection.dart';
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+
+import '../../blog.dart';
+import '../common/material_icon.dart';
+import 'blog_card.dart';
+import 'client/blog_categories.dart';
+
+extension PostIndex on BuildContext {
+ List<({String url, Post post})> get blogPosts {
+ final posts = <({String url, Post post})>[
+ for (final page in pages)
+ if (page.url.startsWith('/blog/'))
+ if (Post.tryParse(page.data.page) case final post?)
+ (url: page.url, post: post),
+ ];
+
+ return posts
+ .sortedBy((entry) => entry.post.dateObject)
+ .reversed
+ .toList(growable: false);
+ }
+}
+
+class BlogIndex extends StatelessComponent {
+ const BlogIndex({super.key});
+
+ @override
+ Component build(BuildContext context) {
+ final posts = context.blogPosts;
+ final categories = context.page.blogCategories;
+
+ return div(classes: 'blog-index', [
+ div(classes: 'blog-index-toolbar', [
+ BlogCategories(categories: categories),
+ a(
+ href: blogAtomFeedPath,
+ classes: 'blog-feed-link',
+ attributes: {
+ 'type': blogAtomMimeType,
+ 'title': context.page.blogAtomSubscribeTitle,
+ },
+ const [
+ MaterialIcon('rss_feed'),
+ span(classes: 'blog-feed-link-label', [.text('Subscribe')]),
+ ],
+ ),
+ ]),
+ div(
+ id: 'blog-container',
+ classes: 'blog-posts-grid',
+ attributes: {'data-selected': 'all'},
+ [
+ for (var i = 0; i < posts.length; i++)
+ BlogCard(
+ post: posts[i].post,
+ url: posts[i].url,
+ priority: switch (i) {
+ 0 => BlogCardPriority.featured,
+ < 5 => BlogCardPriority.high,
+ _ => BlogCardPriority.normal,
+ },
+ className: i == 0
+ ? 'layout-featured'
+ : (i < 5 ? 'layout-grid' : 'layout-list'),
+ ),
+ ],
+ ),
+ ]);
+ }
+}
diff --git a/packages/site_shared/lib/components/blog/blog_next_posts.dart b/packages/site_shared/lib/components/blog/blog_next_posts.dart
new file mode 100644
index 0000000000..6047f2e604
--- /dev/null
+++ b/packages/site_shared/lib/components/blog/blog_next_posts.dart
@@ -0,0 +1,56 @@
+// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+
+import '../../blog.dart';
+import 'blog_card.dart';
+import 'blog_index.dart';
+import 'client/blog_categories.dart';
+
+class BlogNextPosts extends StatelessComponent {
+ const BlogNextPosts({
+ required this.currentPage,
+ required this.category,
+ super.key,
+ });
+
+ final Page currentPage;
+ final BlogCategory? category;
+
+ @override
+ Component build(BuildContext context) {
+ final nextPosts = context.blogPosts
+ .where(
+ (page) =>
+ page.url != currentPage.url &&
+ page.post.category == category?.slug,
+ )
+ .take(2)
+ .toList();
+
+ if (nextPosts.isEmpty) return const .fragment([]);
+
+ final String nextPostsTitle;
+ if (context.page.blogData case {'nextPostsTitle': final String title}) {
+ nextPostsTitle = title;
+ } else {
+ throw Exception('Missing or invalid blog next posts title');
+ }
+
+ return section(classes: 'blog-next-posts', [
+ h2([.text(nextPostsTitle)]),
+ div(classes: 'blog-posts-grid', [
+ for (final post in nextPosts)
+ BlogCard(
+ post: post.post,
+ url: post.url,
+ className: 'layout-grid',
+ ),
+ ]),
+ ]);
+ }
+}
diff --git a/packages/site_shared/lib/components/blog/client/blog_categories.dart b/packages/site_shared/lib/components/blog/client/blog_categories.dart
new file mode 100644
index 0000000000..0c41097f5d
--- /dev/null
+++ b/packages/site_shared/lib/components/blog/client/blog_categories.dart
@@ -0,0 +1,157 @@
+// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+import 'package:universal_web/web.dart' as web;
+
+import '../../../util.dart';
+
+class BlogCategory {
+ const BlogCategory({
+ required this.slug,
+ required this.label,
+ required this.showFeatured,
+ });
+
+ final String slug;
+ final String label;
+ final bool showFeatured;
+
+ @decoder
+ factory BlogCategory.fromMap(Map data) {
+ return BlogCategory(
+ slug: data['slug'] as String,
+ label: data['label'] as String,
+ showFeatured: data['showFeatured'] == true,
+ );
+ }
+ @encoder
+ Map toMap() => {
+ 'slug': slug,
+ 'label': label,
+ 'showFeatured': showFeatured,
+ };
+}
+
+@client
+class BlogCategories extends StatefulComponent {
+ const BlogCategories({required this.categories, super.key});
+
+ final List categories;
+
+ @override
+ State createState() => _BlogCategoriesState();
+}
+
+class _BlogCategoriesState extends State {
+ BlogCategory? selectedCategory;
+
+ @override
+ void initState() {
+ super.initState();
+ if (kIsWeb) {
+ _updateCategoryFromUrl();
+ }
+ }
+
+ void _updateCategoryFromUrl() {
+ final uri = Uri.parse(web.window.location.href);
+ final slug = uri.queryParameters['category'];
+ final category = component.categories
+ .where((c) => c.slug == slug)
+ .firstOrNull;
+
+ setState(() {
+ selectedCategory = category;
+ });
+ _applyLayout(category);
+ }
+
+ void _selectCategory(BlogCategory? category) {
+ if (category == selectedCategory) return;
+
+ setState(() {
+ selectedCategory = category;
+ });
+
+ final uri = Uri.parse(web.window.location.href);
+ final newUri = uri.replace(
+ queryParameters: category == null ? {} : {'category': category.slug},
+ );
+ web.window.history.pushState(null, '', newUri.toString());
+
+ _applyLayout(category);
+ }
+
+ void _applyLayout(BlogCategory? category) {
+ final container = web.document.getElementById('blog-container');
+ if (container != null) {
+ if (category == null) {
+ container.removeAttribute('data-selected');
+ } else {
+ container.setAttribute('data-selected', category.slug);
+ }
+
+ final cards = container.querySelectorAll('.blog-card');
+ final showFeatured = category?.showFeatured ?? true;
+ var visibleCount = showFeatured ? 0 : 1;
+
+ for (var i = 0; i < cards.length; i++) {
+ final card = cards.item(i) as web.Element;
+ final cardCategory = card.getAttribute('data-category');
+
+ final isVisible = category == null || cardCategory == category.slug;
+
+ // Remove existing layout classes
+ card.classList.remove('layout-featured');
+ card.classList.remove('layout-grid');
+ card.classList.remove('layout-list');
+
+ if (isVisible) {
+ if (visibleCount == 0) {
+ card.classList.add('layout-featured');
+ } else if (visibleCount < 5) {
+ card.classList.add('layout-grid');
+ } else {
+ card.classList.add('layout-list');
+ }
+ visibleCount++;
+ }
+ }
+ }
+ }
+
+ @override
+ Component build(BuildContext context) {
+ return div(classes: 'blog-categories', [
+ div(
+ classes: [
+ 'blog-category-chip',
+ if (selectedCategory == null) 'active',
+ ].toClasses,
+ [
+ button(
+ onClick: () => _selectCategory(null),
+ [const .text('All')],
+ ),
+ ],
+ ),
+ const span(classes: 'category-separator', []),
+ for (final category in component.categories)
+ div(
+ classes: [
+ 'blog-category-chip',
+ if (category == selectedCategory) 'active',
+ ].toClasses,
+ [
+ button(
+ onClick: () => _selectCategory(category),
+ [.text(category.label)],
+ ),
+ ],
+ ),
+ ]);
+ }
+}
diff --git a/packages/site_shared/lib/components/blog/client/share_button.dart b/packages/site_shared/lib/components/blog/client/share_button.dart
new file mode 100644
index 0000000000..0fe4bf326e
--- /dev/null
+++ b/packages/site_shared/lib/components/blog/client/share_button.dart
@@ -0,0 +1,115 @@
+// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+import 'package:universal_web/web.dart' as web;
+
+import '../../common/button.dart';
+import '../../common/dropdown.dart';
+import '../../common/material_icon.dart';
+
+@client
+class ShareButton extends StatelessComponent {
+ const ShareButton({
+ required this.url,
+ required this.title,
+ super.key,
+ });
+
+ final String url;
+ final String title;
+
+ @override
+ Component build(BuildContext context) {
+ return Dropdown(
+ id: 'share-menu-${url.hashCode}',
+ toggle: const Button(
+ icon: 'share',
+ title: 'Share this post',
+ ),
+ content: div(classes: 'dropdown-menu', [
+ ul([
+ li([
+ Builder(
+ builder: (context) {
+ return button(
+ onClick: () {
+ web.window.navigator.clipboard.writeText(url);
+ context.findAncestorStateOfType()?.toggle(
+ to: false,
+ );
+ },
+ [
+ const MaterialIcon('content_copy'),
+ const .text('Copy link'),
+ ],
+ );
+ },
+ ),
+ ]),
+ ]),
+ const div(classes: 'dropdown-divider', []),
+ ul([
+ li([
+ a(
+ href:
+ 'https://twitter.com/intent/tweet?text=${Uri.encodeComponent(title)}&url=${Uri.encodeComponent(url)}',
+ target: Target.blank,
+ [
+ const svg([
+ Component.element(
+ tag: 'use',
+ attributes: {
+ 'href': '/packages/site_shared/assets/social/x.svg#x',
+ },
+ ),
+ ]),
+ const .text('Share on X'),
+ ],
+ ),
+ ]),
+ li([
+ a(
+ href:
+ 'https://bsky.app/intent/compose?text=${Uri.encodeComponent("$title $url")}',
+ target: Target.blank,
+ [
+ const svg([
+ Component.element(
+ tag: 'use',
+ attributes: {
+ 'href':
+ '/packages/site_shared/assets/social/bluesky.svg#bluesky',
+ },
+ ),
+ ]),
+ const .text('Share on Bluesky'),
+ ],
+ ),
+ ]),
+ li([
+ a(
+ href:
+ 'https://www.linkedin.com/sharing/share-offsite/?url=${Uri.encodeComponent(url)}',
+ target: Target.blank,
+ [
+ const svg([
+ Component.element(
+ tag: 'use',
+ attributes: {
+ 'href':
+ '/packages/site_shared/assets/social/linkedin.svg#linkedin',
+ },
+ ),
+ ]),
+ const .text('Share on LinkedIn'),
+ ],
+ ),
+ ]),
+ ]),
+ ]),
+ );
+ }
+}
diff --git a/packages/site_shared/lib/components/blog/post_info.dart b/packages/site_shared/lib/components/blog/post_info.dart
new file mode 100644
index 0000000000..36d166de87
--- /dev/null
+++ b/packages/site_shared/lib/components/blog/post_info.dart
@@ -0,0 +1,86 @@
+// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:jaspr/dom.dart';
+import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+
+import '../../src/blog/atom_feed.dart';
+import '../../src/blog/models.dart';
+import '../common/button.dart';
+import 'client/share_button.dart';
+
+class PostInfo extends StatelessComponent {
+ const PostInfo({
+ required this.post,
+ required this.url,
+ super.key,
+ });
+
+ final Post post;
+ final String url;
+
+ @override
+ Component build(BuildContext context) {
+ final authors = context.page.authorsByIds(post.authorIds);
+ final siteUrl = context.page.data.site['url'];
+
+ return div(classes: 'post-info', [
+ div(classes: 'post-info-main', [
+ _AuthorInfo(authors: authors),
+ span(classes: 'post-info-meta', [
+ Component.text('${post.formattedDate} · ${post.readingTime}'),
+ ]),
+ ]),
+ div(classes: 'post-info-actions', [
+ Button(
+ icon: 'rss_feed',
+ href: blogAtomFeedPath,
+ title: context.page.blogAtomSubscribeTitle,
+ attributes: {'type': blogAtomMimeType},
+ ),
+ ShareButton(url: '$siteUrl$url', title: post.title),
+ ]),
+ ]);
+ }
+}
+
+class _AuthorInfo extends StatelessComponent {
+ const _AuthorInfo({required this.authors});
+
+ final List authors;
+
+ @override
+ Component build(BuildContext context) {
+ return div(classes: 'post-info-author', [
+ for (final author in authors)
+ if (author.image case final authorImage? when authorImage.isNotEmpty)
+ img(
+ src: context.resolveAsset('/blog/authors/$authorImage'),
+ alt: author.name,
+ width: 32,
+ height: 32,
+ )
+ else if (author.github?.avatarUrl case final avatarUrl?)
+ img(
+ src: avatarUrl,
+ alt: author.name,
+ width: 32,
+ height: 32,
+ ),
+ h3(classes: 'post-info-name', [
+ for (final (index, author) in authors.indexed) ...[
+ span(classes: 'post-info-author-name', [
+ if (author.linkUrl case final linkUrl?)
+ a(href: linkUrl, target: Target.blank, [.text(author.name)])
+ else
+ .text(author.name),
+ ]),
+ if (index < authors.length - 1)
+ const span(classes: 'post-info-author-separator', [.text(', ')]),
+ ],
+ ]),
+ ]);
+ }
+}
diff --git a/packages/site_shared/lib/src/blog/atom_feed.dart b/packages/site_shared/lib/src/blog/atom_feed.dart
new file mode 100644
index 0000000000..a7fd1cf8b4
--- /dev/null
+++ b/packages/site_shared/lib/src/blog/atom_feed.dart
@@ -0,0 +1,268 @@
+// Copyright 2026 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:collection/collection.dart';
+import 'package:intl/intl.dart';
+import 'package:jaspr/server.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+import 'package:xml/xml.dart';
+
+import 'models.dart';
+
+/// The site-relative path the [BlogAtomFeedOutput] publishes the blog feed at.
+const String blogAtomFeedPath = '/blog/feed.xml';
+
+/// The MIME type of the published Atom feed.
+const String blogAtomMimeType = 'application/atom+xml';
+
+const String _atomNamespace = 'http://www.w3.org/2005/Atom';
+
+final DateFormat _atomDateFormat = DateFormat('yyyy-MM-dd');
+final RegExp _blogIndexPattern = RegExp(r'^blog/index\.(md|html)$');
+
+/// A secondary output that generates an Atom feed for the blog.
+///
+/// Blog posts are pages beneath `/blog/` with a
+/// `publishDate` in their frontmatter.
+/// Posts that set `atom: false` are excluded.
+final class BlogAtomFeedOutput implements SecondaryOutput {
+ const BlogAtomFeedOutput();
+
+ @override
+ Pattern get pattern => _blogIndexPattern;
+
+ @override
+ String createRoute(String _) => blogAtomFeedPath;
+
+ @override
+ Component build(Page page) => Builder(
+ builder: (context) {
+ context.setHeader(
+ 'Content-Type',
+ '$blogAtomMimeType; charset=utf-8',
+ );
+ context.setStatusCode(
+ 200,
+ responseBody: renderAtomFeed(page, context.pages),
+ );
+ return const Component.empty();
+ },
+ );
+
+ /// The Atom feed XML rendered from [blogIndex] and
+ /// the candidate [pages] to include as entries.
+ @visibleForTesting
+ String renderAtomFeed(Page blogIndex, List pages) {
+ final pageData = blogIndex.data;
+ final siteUrl = pageData.site['url'];
+ if (siteUrl is! String) {
+ throw Exception('Site URL not configured in site data.');
+ }
+
+ final siteBaseUrl = Uri.parse(siteUrl);
+ final feedUrl = siteBaseUrl.resolve(blogAtomFeedPath).toString();
+ final blogUrl = siteBaseUrl.resolve('/blog').toString();
+ final feedTitle = pageData.page['title'];
+ if (feedTitle is! String) {
+ throw Exception('Blog index title not configured in page data.');
+ }
+ final feedDescription = pageData.page['description'];
+ if (feedDescription is! String) {
+ throw Exception('Blog index description not configured in page data.');
+ }
+
+ final String feedAuthor;
+ if (pageData['blog'] case {'atom': {'feedAuthor': final String author}}) {
+ feedAuthor = author;
+ } else {
+ throw Exception('Blog atom feed author not configured in page data.');
+ }
+
+ final (:entries, :feedUpdated) = _entriesFor(siteBaseUrl, pages);
+
+ final builder = XmlBuilder();
+ builder.processing('xml', 'version="1.0" encoding="utf-8"');
+ builder.element(
+ 'feed',
+ namespaces: const {_atomNamespace: ''},
+ nest: () {
+ builder
+ ..writeTextElement('title', feedTitle)
+ ..writeTextElement('subtitle', feedDescription)
+ ..element('id', nest: feedUrl)
+ ..writeLink(
+ rel: 'self',
+ type: blogAtomMimeType,
+ href: feedUrl,
+ )
+ ..writeLink(rel: 'alternate', type: 'text/html', href: blogUrl)
+ ..element('updated', nest: feedUpdated.toIso8601String())
+ ..writeAuthor(feedAuthor);
+
+ for (final entry in entries) {
+ entry.writeTo(builder);
+ }
+ },
+ );
+
+ return builder.buildDocument().toXmlString(pretty: true, indent: ' ');
+ }
+
+ /// The entries to write to the feed alongside the
+ /// latest `updated` timestamp across them.
+ ({List<_AtomEntry> entries, DateTime feedUpdated}) _entriesFor(
+ Uri siteBaseUrl,
+ List pages,
+ ) {
+ final entries = <_AtomEntry>[];
+ DateTime? feedUpdated;
+
+ for (final page in pages) {
+ if (!page.url.startsWith('/blog/')) continue;
+ final pageData = page.data.page;
+ if (pageData['atom'] == false) continue;
+ final post = Post.tryParse(pageData);
+ if (post == null) continue;
+
+ final published = _atomDateFormat.parseStrict(post.publishDate, true);
+ final updated = switch (pageData['updatedDate']) {
+ final String updatedDate => _latest(
+ _atomDateFormat.parseStrict(updatedDate, true),
+ published,
+ ),
+ _ => published,
+ };
+
+ entries.add(
+ _AtomEntry(
+ url: siteBaseUrl.resolve(page.url).toString(),
+ title: post.title,
+ summary: post.description,
+ authors: [
+ for (final author in page.authorsByIds(post.authorIds)) author.name,
+ ],
+ category: post.category,
+ published: published,
+ updated: updated,
+ ),
+ );
+ feedUpdated = _latest(feedUpdated, updated);
+ }
+
+ if (feedUpdated == null) {
+ print(
+ 'Warning: No blog posts qualified for the Atom feed. '
+ 'Generated an empty feed.',
+ );
+ }
+
+ return (
+ entries: entries.sorted(),
+ feedUpdated: feedUpdated ?? DateTime.utc(1970),
+ );
+ }
+}
+
+/// The rendered representation of a single blog post in the Atom feed.
+///
+/// Entries sort by [published] descending,
+/// with [url] as a tiebreaker so same-day posts have a stable order.
+final class _AtomEntry implements Comparable<_AtomEntry> {
+ const _AtomEntry({
+ required this.url,
+ required this.title,
+ required this.summary,
+ required this.authors,
+ required this.published,
+ required this.updated,
+ this.category,
+ });
+
+ /// The absolute URL of the blog post.
+ final String url;
+
+ /// The post's title, written to ``.
+ final String title;
+
+ /// The post's description, written to ``.
+ final String summary;
+
+ /// The display names of the post's authors.
+ final List authors;
+
+ /// The time the post was first published.
+ final DateTime published;
+
+ /// The time the post was last updated, or [published] if never updated.
+ final DateTime updated;
+
+ /// The post's category slug,
+ /// written as the `term` of a `` element,
+ /// or `null` if the post has no category.
+ final String? category;
+
+ @override
+ int compareTo(_AtomEntry other) {
+ final byPublished = other.published.compareTo(published);
+ if (byPublished != 0) return byPublished;
+ return url.compareTo(other.url);
+ }
+
+ /// Writes this entry as a complete Atom `` element to [builder].
+ void writeTo(XmlBuilder builder) {
+ builder.element(
+ 'entry',
+ nest: () {
+ builder
+ ..writeTextElement('title', title)
+ ..writeLink(rel: 'alternate', type: 'text/html', href: url)
+ ..element('id', nest: url)
+ ..element('updated', nest: updated.toIso8601String())
+ ..element('published', nest: published.toIso8601String());
+ for (final author in authors) {
+ builder.writeAuthor(author);
+ }
+ builder.writeTextElement('summary', summary);
+ if (category case final category?) {
+ builder.element('category', attributes: {'term': category});
+ }
+ },
+ );
+ }
+}
+
+extension on XmlBuilder {
+ /// Writes an Atom text construct (such as `` or ``) named
+ /// [name] with [value] as its text content.
+ void writeTextElement(String name, String value) {
+ element(name, attributes: const {'type': 'text'}, nest: value);
+ }
+
+ /// Writes an Atom `` element with the given [rel], [type], and [href].
+ void writeLink({
+ required String rel,
+ required String type,
+ required String href,
+ }) {
+ element(
+ 'link',
+ attributes: {'rel': rel, 'type': type, 'href': href},
+ );
+ }
+
+ /// Writes an Atom `` element wrapping a
+ /// single `` set to [name].
+ void writeAuthor(String name) {
+ element('author', nest: () => element('name', nest: name));
+ }
+}
+
+/// The later of [previous] and [candidate],
+/// or [candidate] if [previous] is `null`.
+DateTime _latest(DateTime? previous, DateTime candidate) {
+ if (previous == null || candidate.isAfter(previous)) {
+ return candidate;
+ }
+ return previous;
+}
diff --git a/packages/site_shared/lib/src/blog/models.dart b/packages/site_shared/lib/src/blog/models.dart
new file mode 100644
index 0000000000..61539e28da
--- /dev/null
+++ b/packages/site_shared/lib/src/blog/models.dart
@@ -0,0 +1,127 @@
+// Copyright 2026 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:intl/intl.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+
+import '../../components/blog/client/blog_categories.dart';
+
+extension type Post(Map data) {
+ static Post? tryParse(Map data) {
+ if (data['title'] is! String ||
+ data['description'] is! String ||
+ data['publishDate'] is! String ||
+ !_isValidAuthorProperty(data['author'])) {
+ return null;
+ }
+ return Post(data);
+ }
+
+ String get title => data['title'] as String;
+ String get description => data['description'] as String;
+ String? get image => data['image'] as String?;
+ String get publishDate => data['publishDate'] as String;
+
+ DateTime get dateObject => DateTime.parse(publishDate);
+ String get formattedDate => DateFormat.yMMMd().format(dateObject);
+
+ List get authorIds => switch (data['author']) {
+ final String authorId => [authorId],
+ final List