diff --git a/mu-plugins/blocks/favorite-button/index.php b/mu-plugins/blocks/favorite-button/index.php
new file mode 100644
index 00000000..ce448d68
--- /dev/null
+++ b/mu-plugins/blocks/favorite-button/index.php
@@ -0,0 +1,151 @@
+ '__return_false',
+ 'delete_callback' => '__return_false',
+ 'count' => 0,
+ 'is_favorite' => false,
+ )
+ );
+
+ return $settings;
+}
+
+/**
+ * Initialize the API endpoints.
+ */
+function api_init() {
+ $namespace = 'wporg/v1';
+ $args = array(
+ 'id' => array(
+ 'validate_callback' => function( $param, $request, $key ) {
+ return is_numeric( $param );
+ },
+ 'required' => true,
+ ),
+ );
+ register_rest_route(
+ $namespace,
+ '/favorite',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => __NAMESPACE__ . '\add_favorite',
+ 'args' => $args,
+ 'permission_callback' => 'is_user_logged_in',
+ )
+ );
+ register_rest_route(
+ $namespace,
+ '/favorite',
+ array(
+ 'methods' => \WP_REST_Server::DELETABLE,
+ 'callback' => __NAMESPACE__ . '\delete_favorite',
+ 'args' => $args,
+ 'permission_callback' => 'is_user_logged_in',
+ )
+ );
+}
+
+/**
+ * Set the favorite status for a given item.
+ */
+function add_favorite( $request ) {
+ $id = intval( $request['id'] );
+ $settings = get_block_settings( $id );
+ $result = call_user_func( $settings['add_callback'], $id, $request );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ } else if ( false !== $result ) {
+ if ( is_numeric( $result ) ) {
+ return new \WP_REST_Response( $result, 200 );
+ } else {
+ return new \WP_REST_Response( [ 'success' => true ] );
+ }
+ }
+
+ return new \WP_Error(
+ 'favorite-not-implemented',
+ // Users should never see this error, so we can leave it untranslated.
+ 'The `add_callback` function is not set correctly. It should return a wp_error, integer, or true.',
+ array( 'status' => 500 )
+ );
+}
+
+/**
+ * Remove the favorite status for a given item.
+ */
+function delete_favorite( $request ) {
+ $id = intval( $request['id'] );
+ $settings = get_block_settings( $id );
+ $result = call_user_func( $settings['delete_callback'], $id, $request );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ } else if ( false !== $result ) {
+ if ( is_numeric( $result ) ) {
+ return new \WP_REST_Response( $result, 200 );
+ } else {
+ return new \WP_REST_Response( [ 'success' => true ] );
+ }
+ }
+
+ return new \WP_Error(
+ 'unfavorite-not-implemented',
+ // Users should never see this error, so we can leave it untranslated.
+ 'The `delete_callback` function is not set correctly. It should return a wp_error, integer, or true.',
+ array( 'status' => 500 )
+ );
+}
diff --git a/mu-plugins/blocks/favorite-button/render.php b/mu-plugins/blocks/favorite-button/render.php
new file mode 100644
index 00000000..4f092e79
--- /dev/null
+++ b/mu-plugins/blocks/favorite-button/render.php
@@ -0,0 +1,104 @@
+context['postId'] );
+if ( ! $settings ) {
+ return '';
+}
+
+$user_id = get_current_user_id();
+$show_count = $attributes['showCount'] ?? false;
+$variant = $attributes['variant'] ?? 'default';
+
+if ( ! $user_id && ! $show_count ) {
+ return '';
+}
+
+// Manually enqueue this script, so that it's available for the interactivity view script.
+wp_enqueue_script( 'wp-api-fetch' );
+wp_enqueue_script( 'wp-a11y' );
+
+$is_favorite = $settings['is_favorite'];
+
+$classes = array(
+ $is_favorite ? 'is-favorite' : '',
+ ( 'small' === $variant ) ? 'is-variant-small' : '',
+);
+$classes = implode( ' ', array_filter( $classes ) );
+
+$labels = array(
+ 'add' => __( 'Add to favorites', 'wporg' ),
+ 'remove' => __( 'Remove from favorites', 'wporg' ),
+ 'favorited' => __( 'Favorited', 'wporg' ),
+ 'unfavorited' => __( 'Removed from favorites', 'wporg' ),
+ // translators: %s: number of users who favorited this item.
+ 'screenReader' => __( 'Favorited %s times', 'wporg' ),
+);
+
+$sr_label = sprintf(
+ // translators: %s: number of users who favorited this item.
+ _n( 'Favorited %s time', 'Favorited %s times', $settings['count'], 'wporg' ),
+ $settings['count']
+);
+
+// Initial state to pass to Interactivity API.
+$init_state = [
+ 'id' => $block->context['postId'],
+ 'count' => $settings['count'],
+ 'isFavorite' => $is_favorite,
+ 'label' => $labels,
+];
+$encoded_state = wp_json_encode( $init_state );
+
+?>
+
$classes ] ); // phpcs:ignore ?>
+ data-wp-interactive="wporg/favorite-button"
+ data-wp-context=""
+ data-wp-class--is-favorite="context.isFavorite"
+>
+
+
+
+
+
+
diff --git a/mu-plugins/blocks/favorite-button/src/block.json b/mu-plugins/blocks/favorite-button/src/block.json
new file mode 100644
index 00000000..f06dd32d
--- /dev/null
+++ b/mu-plugins/blocks/favorite-button/src/block.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "wporg/favorite-button",
+ "version": "0.1.0",
+ "title": "Favorite Button",
+ "category": "design",
+ "icon": "",
+ "description": "A button to toggle favoriting on the current item.",
+ "textdomain": "wporg",
+ "supports": {
+ "html": false
+ },
+ "attributes": {
+ "showCount": {
+ "type": "boolean",
+ "default": false
+ },
+ "variant": {
+ "type": "string",
+ "enum": [ "default", "small" ],
+ "default": "default"
+ }
+ },
+ "usesContext": [ "postId", "postType" ],
+ "editorScript": "file:./index.js",
+ "style": "file:./style-index.css",
+ "viewScriptModule": "file:./view.js",
+ "render": "file:../render.php"
+}
diff --git a/mu-plugins/blocks/favorite-button/src/index.js b/mu-plugins/blocks/favorite-button/src/index.js
new file mode 100644
index 00000000..4f621505
--- /dev/null
+++ b/mu-plugins/blocks/favorite-button/src/index.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType } from '@wordpress/blocks';
+import ServerSideRender from '@wordpress/server-side-render';
+import { useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import './style.scss';
+
+function Edit( { attributes, name } ) {
+ return (
+
+
+
+ );
+}
+
+registerBlockType( metadata.name, {
+ edit: Edit,
+ save: () => null,
+} );
diff --git a/mu-plugins/blocks/favorite-button/src/style.scss b/mu-plugins/blocks/favorite-button/src/style.scss
new file mode 100644
index 00000000..51db108b
--- /dev/null
+++ b/mu-plugins/blocks/favorite-button/src/style.scss
@@ -0,0 +1,67 @@
+:where(.wp-block-wporg-favorite-button) {
+ .wporg-favorite-button__button {
+ margin: 0;
+ padding:
+ var(--wp--custom--button--small--spacing--padding--top)
+ calc(var(--wp--custom--button--small--spacing--padding--right) - 4px)
+ var(--wp--custom--button--small--spacing--padding--bottom)
+ calc(var(--wp--custom--button--small--spacing--padding--left) - 4px);
+ background: none;
+ border: 1px solid var(--wp--preset--color--light-grey-1);
+ border-radius: 2px;
+ box-shadow: none;
+ font-size: 14px;
+ color: var(--wp--preset--color--charcoal-1);
+
+ &:where(button) {
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--wp--preset--color--light-grey-2);
+ }
+
+ &:focus {
+ border-color: transparent;
+ }
+
+ &:active {
+ border-color: transparent;
+ background-color: var(--wp--preset--color--charcoal-1);
+ color: var(--wp--preset--color--white);
+
+ path {
+ fill: currentcolor;
+ }
+ }
+ }
+ }
+
+ > * {
+
+ /* Align children. */
+ display: flex !important;
+ align-items: center;
+ gap: calc(var(--wp--preset--spacing--10) / 2);
+ }
+
+ svg {
+ height: 24px;
+ width: 24px;
+ overflow: visible;
+
+ path {
+ fill: var(--wp--preset--color--charcoal-4);
+ }
+
+ &[hidden] {
+ display: none;
+ }
+ }
+
+ &.is-variant-small {
+ .wporg-favorite-button__button {
+ border: none;
+ padding: 2px 4px;
+ }
+ }
+}
diff --git a/mu-plugins/blocks/favorite-button/src/view.js b/mu-plugins/blocks/favorite-button/src/view.js
new file mode 100644
index 00000000..0853cfd7
--- /dev/null
+++ b/mu-plugins/blocks/favorite-button/src/view.js
@@ -0,0 +1,53 @@
+/**
+ * WordPress dependencies
+ */
+import { getContext, store } from '@wordpress/interactivity';
+
+store( 'wporg/favorite-button', {
+ state: {
+ get labelAction() {
+ const { label, isFavorite } = getContext();
+ return isFavorite ? label.remove : label.add;
+ },
+ get labelCount() {
+ const { count } = getContext();
+ return `${ count }`;
+ },
+ get labelScreenReader() {
+ const { label, count } = getContext();
+ return label.screenReader.replace( '%s', count );
+ },
+ },
+ actions: {
+ *triggerAction() {
+ const context = getContext();
+ if ( context.isFavorite ) {
+ try {
+ const result = yield wp.apiFetch( {
+ path: '/wporg/v1/favorite',
+ method: 'DELETE',
+ data: { id: context.id },
+ } );
+ if ( 'number' === typeof result ) {
+ context.count = result;
+ }
+ context.isFavorite = false;
+ wp.a11y.speak( context.label.unfavorited, 'polite' );
+ } catch ( error ) {}
+ } else {
+ try {
+ const result = yield wp.apiFetch( {
+ path: '/wporg/v1/favorite',
+ method: 'POST',
+ data: { id: context.id },
+ } );
+ if ( 'number' === typeof result ) {
+ context.count = result;
+ }
+ context.isFavorite = true;
+ wp.a11y.speak( context.label.favorited, 'polite' );
+ } catch ( error ) {}
+ }
+ },
+ },
+} );
diff --git a/mu-plugins/loader.php b/mu-plugins/loader.php
index 486a31bc..23b24e49 100644
--- a/mu-plugins/loader.php
+++ b/mu-plugins/loader.php
@@ -27,6 +27,7 @@
}
require_once __DIR__ . '/helpers/helpers.php';
+require_once __DIR__ . '/blocks/favorite-button/index.php';
require_once __DIR__ . '/blocks/global-header-footer/blocks.php';
require_once __DIR__ . '/blocks/google-map/index.php';
require_once __DIR__ . '/blocks/handbook-meta-link/block.php';