diff --git a/package.json b/package.json
index 2e15fd6858..2c08839d6d 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,7 @@
"react-medium-image-zoom": "^5.4.1",
"react-select": "^5.7.0",
"remark-gfm": "^1.0.0",
+ "swr": "^2.4.0",
"tailwind-merge": "^2.5.5",
"typescript": "^4.6.3",
"use-keyboard-shortcut": "^1.1.6",
diff --git a/src/components/Examples/ExamplesFilter.tsx b/src/components/Examples/ExamplesFilter.tsx
index a15d3bda3a..b2ac11a9a3 100644
--- a/src/components/Examples/ExamplesFilter.tsx
+++ b/src/components/Examples/ExamplesFilter.tsx
@@ -2,15 +2,15 @@ import React, { ChangeEvent, Dispatch, SetStateAction, useCallback, useEffect, u
import ReactDOM from 'react-dom';
import { Input } from 'src/components/ui/Input';
import { products } from '../../data/examples';
-import Button from '@ably/ui/core/Button';
+import Button from 'src/components/ui/Button';
import cn from 'src/utilities/cn';
-import Badge from '@ably/ui/core/Badge';
+import Badge from 'src/components/ui/Badge';
import ExamplesCheckbox from './ExamplesCheckbox';
import { SelectedFilters } from './ExamplesContent';
import { useOnClickOutside } from 'src/hooks/use-on-click-outside';
import { navigate } from 'gatsby';
import { ProductName } from '@ably/ui/core/ProductTile/data';
-import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import { AdjustmentsHorizontalIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline';
// Matches Tailwind's `sm` screen (768px), where the filter switches from the
// mobile drawer (with an Apply button) to the inline desktop sidebar. Above
@@ -135,7 +135,7 @@ const ExamplesFilter = ({
}
onClick={() => setExpandFilterMenu(true)}
>
Filter
diff --git a/src/components/Examples/ExamplesGrid.tsx b/src/components/Examples/ExamplesGrid.tsx
index 6f1ab43e1e..285925a667 100644
--- a/src/components/Examples/ExamplesGrid.tsx
+++ b/src/components/Examples/ExamplesGrid.tsx
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
-import Badge from '@ably/ui/core/Badge';
+import Badge from 'src/components/ui/Badge';
import Icon from 'src/components/Icon';
import { IconName } from 'src/components/Icon/types';
import { ProductName, products as dataProducts } from '@ably/ui/core/ProductTile/data';
diff --git a/src/components/Examples/ExamplesNoResults.tsx b/src/components/Examples/ExamplesNoResults.tsx
index ddab95d64c..b8d23c10e1 100644
--- a/src/components/Examples/ExamplesNoResults.tsx
+++ b/src/components/Examples/ExamplesNoResults.tsx
@@ -1,4 +1,4 @@
-import Badge from '@ably/ui/core/Badge';
+import Badge from 'src/components/ui/Badge';
import { Link } from 'gatsby';
const ExamplesNoResults = () => {
diff --git a/src/components/Homepage/Changelog.tsx b/src/components/Homepage/Changelog.tsx
index b98f44282b..f98b95950e 100644
--- a/src/components/Homepage/Changelog.tsx
+++ b/src/components/Homepage/Changelog.tsx
@@ -1,8 +1,8 @@
import { useStaticQuery } from 'gatsby';
import { graphql } from 'gatsby';
import { Key } from 'react';
-import Badge from '@ably/ui/core/Badge';
-import FeaturedLink from '@ably/ui/core/FeaturedLink';
+import Badge from 'src/components/ui/Badge';
+import FeaturedLink from 'src/components/ui/FeaturedLink';
import Link from '../Link';
interface ChangelogFeedItemNode {
diff --git a/src/components/Homepage/ExamplesSection.tsx b/src/components/Homepage/ExamplesSection.tsx
index 6caae9c4a2..dd2995ccc5 100644
--- a/src/components/Homepage/ExamplesSection.tsx
+++ b/src/components/Homepage/ExamplesSection.tsx
@@ -1,7 +1,7 @@
import type { ExamplesSectionData } from 'src/data/content/types';
import { getImageFromList, ImageProps } from 'src/components/Image';
import { Image } from 'src/components/Image';
-import FeaturedLink from '@ably/ui/core/FeaturedLink';
+import FeaturedLink from 'src/components/ui/FeaturedLink';
export const ExamplesSection = ({ section, images }: { section: ExamplesSectionData; images: ImageProps[] }) => {
const imageUrl = getImageFromList(images, section.image);
diff --git a/src/components/Icon/types.ts b/src/components/Icon/types.ts
index c9141408a3..5f3be4e9b8 100644
--- a/src/components/Icon/types.ts
+++ b/src/components/Icon/types.ts
@@ -3,3 +3,5 @@ import { glyphs } from './glyphs';
// Static glyph names are checked against the vendored registry; `icon-tech-${language}`
// stays open for the dynamically-built language icons.
export type IconName = keyof typeof glyphs | `icon-tech-${string}`;
+
+export type IconSize = `${number}px` | `${number}em` | `${number}rem` | `calc(${string})`;
diff --git a/src/components/Layout/Footer.tsx b/src/components/Layout/Footer.tsx
index bc31e4b8dc..530a6df72c 100644
--- a/src/components/Layout/Footer.tsx
+++ b/src/components/Layout/Footer.tsx
@@ -2,11 +2,11 @@ import React, { ComponentType, SVGProps, useEffect, useMemo, useState } from 're
import { useLocation } from '@reach/router';
import Icon from 'src/components/Icon';
import { IconName } from 'src/components/Icon/types';
-import Status, { StatusUrl } from '@ably/ui/core/Status';
+import Status, { StatusUrl } from 'src/components/ui/Status';
import cn from 'src/utilities/cn';
import type { PageContextType } from './Layout';
import { useLayoutContext } from 'src/contexts/layout-context';
-import Button from '@ably/ui/core/Button';
+import Button from 'src/components/ui/Button';
import { HandRaisedIcon, HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline';
import {
HandRaisedIcon as HandRaisedSolidIcon,
diff --git a/src/components/Layout/Header.test.tsx b/src/components/Layout/Header.test.tsx
index dd675edc13..b205281c92 100644
--- a/src/components/Layout/Header.test.tsx
+++ b/src/components/Layout/Header.test.tsx
@@ -23,7 +23,7 @@ jest.mock('src/components/Icon', () => {
return MockIcon;
});
-jest.mock('@ably/ui/core/LinkButton', () => {
+jest.mock('src/components/ui/LinkButton', () => {
const MockButton: React.FC<{ children: React.ReactNode }> = ({ children }) => ;
MockButton.displayName = 'MockButton';
return MockButton;
diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx
index de7305490e..78abe89f2d 100644
--- a/src/components/Layout/Header.tsx
+++ b/src/components/Layout/Header.tsx
@@ -7,7 +7,7 @@ import { throttle } from 'es-toolkit/compat';
import cn from 'src/utilities/cn';
import Icon from 'src/components/Icon';
import TabMenu from '@ably/ui/core/TabMenu';
-import Logo from '@ably/ui/core/images/logo/ably-logo.svg';
+import Logo from 'src/images/ably-logo.svg';
import { track } from '@ably/ui/core/insights';
import { componentMaxHeight, HEADER_BOTTOM_MARGIN, HEADER_HEIGHT } from 'src/utilities/heights';
import LeftSidebar from './LeftSidebar';
diff --git a/src/components/Layout/LanguageSelector.test.tsx b/src/components/Layout/LanguageSelector.test.tsx
index b37a10ca4c..263c7fd3bb 100644
--- a/src/components/Layout/LanguageSelector.test.tsx
+++ b/src/components/Layout/LanguageSelector.test.tsx
@@ -14,7 +14,7 @@ jest.mock('src/components/Icon', () => ({
default: ({ name }: { name: string }) =>
{name}
,
}));
-jest.mock('@ably/ui/core/Badge', () => ({
+jest.mock('src/components/ui/Badge', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => {children}
,
}));
diff --git a/src/components/Layout/LanguageSelector.tsx b/src/components/Layout/LanguageSelector.tsx
index 36a4fe9291..02e37f22a9 100644
--- a/src/components/Layout/LanguageSelector.tsx
+++ b/src/components/Layout/LanguageSelector.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useLocation } from '@reach/router';
-import Badge from '@ably/ui/core/Badge';
+import Badge from 'src/components/ui/Badge';
import Icon from 'src/components/Icon';
import { IconName } from 'src/components/Icon/types';
import cn from 'src/utilities/cn';
diff --git a/src/components/Layout/mdx/RequiredBadge.tsx b/src/components/Layout/mdx/RequiredBadge.tsx
index 3770452d9f..7a28fab4db 100644
--- a/src/components/Layout/mdx/RequiredBadge.tsx
+++ b/src/components/Layout/mdx/RequiredBadge.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import Badge from '@ably/ui/core/Badge';
+import Badge from 'src/components/ui/Badge';
// The wrapping div with data-toc-exclude prevents this badge's text from
// appearing in the "On this page" sidebar when rendered inside a heading.
diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx
new file mode 100644
index 0000000000..aa41383ef3
--- /dev/null
+++ b/src/components/ui/Badge.tsx
@@ -0,0 +1,164 @@
+import React, { PropsWithChildren, ReactNode, useMemo } from 'react';
+import { IconSize } from 'src/components/Icon/types';
+import cn from 'src/utilities/cn';
+import { ColorClassColorGroups } from './colors';
+import IconSlot from './IconSlot';
+
+/**
+ * Props for the Badge component.
+ */
+export interface BadgeProps {
+ /**
+ * The size of the badge. Can be one of "xs", "sm", "md", or "lg".
+ */
+ size?: 'xs' | 'sm' | 'md' | 'lg';
+
+ /**
+ * The color of the badge. Can be a value from ColorClassColorGroups or "red".
+ */
+ color?: ColorClassColorGroups | 'red';
+
+ /**
+ * An icon element to display before the children. Pass it unsized — the badge sizes it.
+ */
+ iconBefore?: ReactNode;
+
+ /**
+ * An icon element to display after the children. Pass it unsized — the badge sizes it.
+ */
+ iconAfter?: ReactNode;
+
+ /**
+ * Additional CSS class names to apply to the badge.
+ */
+ className?: string;
+
+ /**
+ * Whether the badge is disabled. Defaults to false.
+ */
+ disabled?: boolean;
+
+ /**
+ * Whether the badge is focusable. Defaults to false.
+ */
+ focusable?: boolean;
+
+ /**
+ * Whether the badge is hoverable. Defaults to false.
+ */
+ hoverable?: boolean;
+
+ /**
+ * The size of the icons in the badge. If not provided, it will be derived from the badge size.
+ */
+ iconSize?: IconSize;
+
+ /**
+ * Accessible label for the badge when interactive
+ */
+ ariaLabel?: string;
+
+ /**
+ * Additional CSS class names to apply to the children of the badge.
+ */
+ childClassName?: string;
+}
+
+const defaultIconSizeByBadgeSize: Record, IconSize> = {
+ lg: '16px',
+ md: '15px',
+ sm: '14px',
+ xs: '13px',
+};
+
+const Badge: React.FC> = ({
+ size = 'md',
+ color = 'neutral',
+ iconBefore,
+ iconAfter,
+ className,
+ childClassName,
+ children,
+ disabled = false,
+ focusable = false,
+ hoverable = false,
+ iconSize,
+ ariaLabel,
+}) => {
+ const sizeClass = useMemo(() => {
+ switch (size) {
+ case 'xs':
+ return 'px-2 py-0 text-[10px] leading-tight';
+ case 'sm':
+ return 'px-2 py-0.5 text-[10px] leading-tight';
+ case 'md':
+ return 'px-2.5 py-0.5 text-[11px] leading-normal';
+ case 'lg':
+ return 'px-3 py-[0.1875rem] text-[12px] leading-normal';
+ }
+ }, [size]);
+
+ const childClass = useMemo(() => {
+ switch (size) {
+ case 'xs':
+ case 'sm':
+ return 'leading-[18px]';
+ case 'md':
+ case 'lg':
+ return 'leading-[20px]';
+ }
+ }, [size]);
+
+ const colorClass = useMemo(() => {
+ switch (color) {
+ case 'neutral':
+ return 'text-neutral-900 dark:text-neutral-400';
+ case 'violet':
+ return 'text-violet-400';
+ case 'orange':
+ return 'text-orange-600';
+ case 'yellow':
+ return 'text-yellow-600';
+ case 'green':
+ return 'text-green-600';
+ case 'blue':
+ return 'text-blue-600';
+ case 'pink':
+ return 'text-pink-600';
+ case 'red':
+ return 'text-orange-700';
+ }
+ }, [color]);
+
+ const computedIconSize = iconSize ?? defaultIconSizeByBadgeSize[size];
+
+ return (
+
+ {iconBefore ? : null}
+
+ {children}
+
+ {iconAfter ? : null}
+
+ );
+};
+
+export default Badge;
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
new file mode 100644
index 0000000000..f2b3f6c8a4
--- /dev/null
+++ b/src/components/ui/Button.tsx
@@ -0,0 +1,125 @@
+import React, { PropsWithChildren, ReactNode } from 'react';
+import cn from 'src/utilities/cn';
+import { ColorClass, ColorThemeSet } from './colors';
+import IconSlot from './IconSlot';
+
+export type ButtonType = 'priority' | 'primary' | 'secondary';
+
+type ButtonSize = 'lg' | 'md' | 'sm' | 'xs';
+
+export type ButtonPropsBase = {
+ /**
+ * The type of button: priority, primary, or secondary.
+ */
+ variant?: ButtonType;
+ /**
+ * The button size: lg, sm, or xs. Leave empty for md.
+ */
+ size?: ButtonSize;
+ /**
+ * An icon element to render on the left side of the button label. Pass a Heroicon
+ * (e.g. ``) or an Ably glyph (``) unsized — the
+ * button sizes it.
+ */
+ leftIcon?: ReactNode;
+ /**
+ * An icon element to render on the right side of the button label. See `leftIcon`.
+ */
+ rightIcon?: ReactNode;
+ /**
+ * Optional classes to add to the button element.
+ */
+ className?: string;
+ /**
+ * Optional color to apply to the icon on either left and/or right side of the button.
+ */
+ iconColor?: ColorClass | ColorThemeSet;
+};
+
+type ButtonProps = ButtonPropsBase & React.ButtonHTMLAttributes;
+
+// got to go the long way round because of ol' mate Taily Waily
+const buttonClasses: Record> = {
+ priority: {
+ lg: 'ui-button-priority-lg',
+ md: 'ui-button-priority',
+ sm: 'ui-button-priority-sm',
+ xs: 'ui-button-priority-xs',
+ },
+ primary: {
+ lg: 'ui-button-primary-lg',
+ md: 'ui-button-primary',
+ sm: 'ui-button-primary-sm',
+ xs: 'ui-button-primary-xs',
+ },
+ secondary: {
+ lg: 'ui-button-secondary-lg',
+ md: 'ui-button-secondary',
+ sm: 'ui-button-secondary-sm',
+ xs: 'ui-button-secondary-xs',
+ },
+};
+
+export const iconModifierClasses: Record = {
+ lg: { left: 'ui-button-lg-left-icon', right: 'ui-button-lg-right-icon' },
+ md: { left: 'ui-button-left-icon', right: 'ui-button-right-icon' },
+ sm: { left: 'ui-button-sm-left-icon', right: 'ui-button-sm-right-icon' },
+ xs: { left: '', right: '' },
+};
+
+export const commonButtonProps = (props: ButtonPropsBase) => {
+ const { variant = 'primary', size, leftIcon, rightIcon, className } = props;
+
+ return {
+ className: cn(
+ buttonClasses[variant][size ?? 'md'],
+ { [iconModifierClasses[size ?? 'md'].left]: leftIcon },
+ { [iconModifierClasses[size ?? 'md'].right]: rightIcon },
+ className,
+ ),
+ };
+};
+
+// Per-size icon dimensions, matching the `[&>svg]:!w-6/5/4` rules the button base classes
+// applied to a direct