diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts
index 3e08e6d831..679e7350eb 100644
--- a/frontend/packages/ui/src/index.ts
+++ b/frontend/packages/ui/src/index.ts
@@ -42,3 +42,8 @@ export type {
} from "./lib/utilities/Swatch";
export { Label } from "./lib/controls/utilities/Label";
export type { LabelProps } from "./lib/controls/utilities/Label";
+export { HintMessage } from "./lib/controls/utilities/HintMessage";
+export type {
+ HintMessageProps,
+ HintMessageType,
+} from "./lib/controls/utilities/HintMessage";
diff --git a/frontend/packages/ui/src/lib/controls/utilities/HintMessage.module.scss b/frontend/packages/ui/src/lib/controls/utilities/HintMessage.module.scss
new file mode 100644
index 0000000000..fe02874dc9
--- /dev/null
+++ b/frontend/packages/ui/src/lib/controls/utilities/HintMessage.module.scss
@@ -0,0 +1,27 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+@use "../../_ds/typography.scss" as t;
+
+.hint-message {
+ --hint-color: var(--color-foreground-secondary);
+
+ @include t.use-typography("body-small");
+
+ color: var(--hint-color);
+}
+
+.type-hint {
+ --hint-color: var(--color-foreground-secondary);
+}
+
+.type-warning {
+ --hint-color: var(--color-accent-warning);
+}
+
+.type-error {
+ --hint-color: var(--color-foreground-error);
+}
diff --git a/frontend/packages/ui/src/lib/controls/utilities/HintMessage.spec.tsx b/frontend/packages/ui/src/lib/controls/utilities/HintMessage.spec.tsx
new file mode 100644
index 0000000000..09377cf5c2
--- /dev/null
+++ b/frontend/packages/ui/src/lib/controls/utilities/HintMessage.spec.tsx
@@ -0,0 +1,77 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import { render } from "@testing-library/react";
+import { HintMessage } from "./HintMessage";
+
+describe("HintMessage", () => {
+ it("should render successfully", () => {
+ const { baseElement } = render();
+ expect(baseElement).toBeTruthy();
+ });
+
+ it("should render a
root element", () => {
+ const { container } = render(
);
+ expect(container.firstElementChild?.tagName).toBe("DIV");
+ });
+
+ it("should render the message in a span", () => {
+ const { container } = render(
+
,
+ );
+ const span = container.querySelector("span");
+ expect(span?.textContent).toBe("Some hint text");
+ });
+
+ it("should set span id to `${id}-hint`", () => {
+ const { container } = render(
);
+ const span = container.querySelector("span");
+ expect(span?.getAttribute("id")).toBe("my-field-hint");
+ });
+
+ it("should not render span when message is undefined", () => {
+ const { container } = render(
);
+ expect(container.querySelector("span")).toBeNull();
+ });
+
+ it("should not set aria-live for type hint (default)", () => {
+ const { container } = render(
);
+ const div = container.firstElementChild;
+ expect(div?.getAttribute("aria-live")).toBeNull();
+ });
+
+ it("should set aria-live=polite for type warning", () => {
+ const { container } = render(
+
,
+ );
+ const div = container.firstElementChild;
+ expect(div?.getAttribute("aria-live")).toBe("polite");
+ });
+
+ it("should set aria-live=polite for type error", () => {
+ const { container } = render(
+
,
+ );
+ const div = container.firstElementChild;
+ expect(div?.getAttribute("aria-live")).toBe("polite");
+ });
+
+ it("should merge className", () => {
+ const { container } = render(
+
,
+ );
+ const div = container.firstElementChild;
+ expect(div?.getAttribute("class")).toContain("extra");
+ });
+
+ it("should pass through HTML attributes", () => {
+ const { container } = render(
+
,
+ );
+ const div = container.firstElementChild;
+ expect(div?.getAttribute("data-testid")).toBe("h");
+ });
+});
diff --git a/frontend/packages/ui/src/lib/controls/utilities/HintMessage.stories.tsx b/frontend/packages/ui/src/lib/controls/utilities/HintMessage.stories.tsx
new file mode 100644
index 0000000000..7de5854c37
--- /dev/null
+++ b/frontend/packages/ui/src/lib/controls/utilities/HintMessage.stories.tsx
@@ -0,0 +1,34 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { HintMessage } from "./HintMessage";
+
+const meta = {
+ title: "Controls/Utilities/HintMessage",
+ component: HintMessage,
+ args: {
+ id: "field",
+ message: "This is a hint message.",
+ },
+} satisfies Meta
;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Warning: Story = {
+ args: { type: "warning", message: "This value is unusual." },
+};
+
+export const Error: Story = {
+ args: { type: "error", message: "This field is required." },
+};
+
+export const NoMessage: Story = {
+ args: { message: undefined },
+};
diff --git a/frontend/packages/ui/src/lib/controls/utilities/HintMessage.tsx b/frontend/packages/ui/src/lib/controls/utilities/HintMessage.tsx
new file mode 100644
index 0000000000..f5e56e5782
--- /dev/null
+++ b/frontend/packages/ui/src/lib/controls/utilities/HintMessage.tsx
@@ -0,0 +1,51 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import { type ComponentPropsWithRef, memo } from "react";
+import clsx from "clsx";
+import styles from "./HintMessage.module.scss";
+
+export type HintMessageType = "hint" | "warning" | "error";
+
+export interface HintMessageProps extends ComponentPropsWithRef<"div"> {
+ /** Unique id – the inner span gets id `${id}-hint` */
+ id: string;
+ /** The message to display */
+ message?: string;
+ /** Visual/semantic type of the hint */
+ type?: HintMessageType;
+}
+
+function HintMessageInner({
+ id,
+ message,
+ type = "hint",
+ className,
+ ...rest
+}: HintMessageProps) {
+ const ariaLive =
+ type === "warning" || type === "error" ? "polite" : undefined;
+
+ return (
+
+ {message != null && (
+
+ {message}
+
+ )}
+
+ );
+}
+
+export const HintMessage = memo(HintMessageInner);