diff --git a/packages/canon/src/components/Select/Select.stories.tsx b/packages/canon/src/components/Select/Select.stories.tsx
new file mode 100644
index 0000000000..3d4849c393
--- /dev/null
+++ b/packages/canon/src/components/Select/Select.stories.tsx
@@ -0,0 +1,307 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { Select } from './Select';
+import { Form } from '@base-ui-components/react/form';
+import { Button } from '../Button';
+
+const meta = {
+ title: 'Components/Select',
+ component: Select,
+ decorators: [story =>
{story()}
],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const fontOptions = [
+ { value: 'sans', label: 'Sans-serif' },
+ { value: 'serif', label: 'Serif' },
+ { value: 'mono', label: 'Monospace' },
+ { value: 'cursive', label: 'Cursive' },
+];
+
+export const Default: Story = {
+ args: {
+ label: 'Font Family',
+ options: fontOptions,
+ placeholder: 'Select a font',
+ name: 'font',
+ },
+};
+
+export const WithDescription: Story = {
+ args: {
+ ...Default.args,
+ description: 'Choose a font family for your document',
+ },
+};
+
+export const Required: Story = {
+ args: {
+ ...Default.args,
+ required: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ ...Default.args,
+ disabled: true,
+ },
+};
+
+export const DisabledOption: Story = {
+ args: {
+ ...Default.args,
+ options: [
+ ...fontOptions,
+ { value: 'comic-sans', label: 'Comic sans', disabled: true },
+ ],
+ },
+};
+
+export const NoLabel: Story = {
+ args: {
+ ...Default.args,
+ label: undefined,
+ },
+};
+
+export const NoOptions: Story = {
+ args: {
+ ...Default.args,
+ options: undefined,
+ },
+};
+
+export const Small: Story = {
+ args: {
+ ...Default.args,
+ size: 'small',
+ },
+};
+
+export const WithValue: Story = {
+ args: {
+ ...Default.args,
+ value: 'mono',
+ defaultValue: 'serif',
+ },
+};
+
+export const WithDefaultValue: Story = {
+ args: {
+ ...Default.args,
+ defaultValue: 'serif',
+ options: fontOptions,
+ name: 'font',
+ },
+};
+
+const generateOptions = (count = 100) => {
+ const firstWords = [
+ 'Moon',
+ 'Sun',
+ 'Star',
+ 'Cosmic',
+ 'Globe',
+ 'Flux',
+ 'Nova',
+ 'Echo',
+ 'Pulse',
+ 'Vertex',
+ 'Nexus',
+ 'Orbit',
+ 'Prism',
+ 'Quantum',
+ 'Zenith',
+ 'Aura',
+ 'Crystal',
+ 'Shadow',
+ 'Phantom',
+ 'Azure',
+ 'Ember',
+ 'Frost',
+ 'Horizon',
+ 'Mystic',
+ 'Raven',
+ 'Solstice',
+ 'Tempest',
+ 'Vortex',
+ 'Whisper',
+ 'Zephyr',
+ ];
+
+ const secondWords = [
+ 'green',
+ 'blue',
+ 'red',
+ 'black',
+ 'white',
+ 'silver',
+ 'gold',
+ 'copper',
+ 'bronze',
+ 'steel',
+ 'flow',
+ 'light',
+ 'dark',
+ 'dream',
+ 'stream',
+ 'life',
+ 'sight',
+ 'mind',
+ 'craft',
+ 'blend',
+ 'wave',
+ 'swift',
+ 'sharp',
+ 'soft',
+ 'bold',
+ 'clear',
+ 'deep',
+ 'lift',
+ 'shift',
+ 'grace',
+ ];
+
+ const thirdWords = [
+ 'Sans',
+ 'Serif',
+ 'Mono',
+ 'Script',
+ 'Display',
+ 'Slab',
+ 'Round',
+ 'Thin',
+ 'Bold',
+ 'Italic',
+ 'Pro',
+ 'Neo',
+ 'Prime',
+ 'Plus',
+ 'One',
+ 'Two',
+ 'Nova',
+ 'Ultra',
+ 'Elite',
+ 'Max',
+ 'Type',
+ 'Text',
+ 'View',
+ 'Graph',
+ 'Print',
+ 'Read',
+ 'Write',
+ 'Book',
+ 'Note',
+ 'Letter',
+ ];
+
+ const randomElement = (array: T[]): T =>
+ array[Math.floor(Math.random() * array.length)];
+
+ const uniqueRandomNames = Array.from({ length: count })
+ .map(() => {
+ const firstName = randomElement(firstWords);
+ const secondName = randomElement(secondWords);
+ const thirdName = randomElement(thirdWords);
+ return `${firstName}${secondName} ${thirdName}`;
+ })
+ .reduce((accSet, label) => {
+ accSet.add(label);
+ return accSet;
+ }, new Set())
+ .values();
+
+ return Array.from(uniqueRandomNames).map(label => ({
+ value: label.toLocaleLowerCase('en-US').replaceAll(' ', '-'),
+ label,
+ }));
+};
+
+export const WithManyOptions: Story = {
+ args: {
+ label: 'Font Family',
+ options: generateOptions(),
+ name: 'font',
+ },
+};
+
+async function validateFont(value: string) {
+ // Mimic a server response
+ await new Promise(resolve => {
+ setTimeout(resolve, 500);
+ });
+
+ const restrictedFonts = ['comic-sans'];
+ if (restrictedFonts.includes(value)) {
+ return { error: 'This font should not be allowed.' };
+ }
+
+ return { success: true };
+}
+
+export const ShowErrorOnSubmit: Story = {
+ args: {
+ ...Default.args,
+ label: 'Font Family (select Comic sans to see error)',
+ options: [...fontOptions, { value: 'comic-sans', label: 'Comic sans' }],
+ required: true,
+ },
+ decorators: [
+ Story => {
+ const [errors, setErrors] = useState({});
+ const [loading, setLoading] = useState(false);
+
+ return (
+
+ );
+ },
+ ],
+};
diff --git a/packages/canon/src/components/Select/Select.styles.css b/packages/canon/src/components/Select/Select.styles.css
new file mode 100644
index 0000000000..b1a7e53b9a
--- /dev/null
+++ b/packages/canon/src/components/Select/Select.styles.css
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+.canon-SelectFieldRoot {
+ display: flex;
+ flex-direction: column;
+ font-family: var(--canon-font-regular);
+ width: 100%;
+}
+
+.canon-SelectFieldLabel {
+ font-size: var(--canon-font-size-2);
+ font-weight: var(--canon-font-weight-regular);
+ color: var(--canon-fg-primary);
+ margin-bottom: var(--canon-space-1_5);
+}
+
+.canon-SelectFieldDescription {
+ font-size: var(--canon-font-size-2);
+ font-weight: var(--canon-font-weight-regular);
+ color: var(--canon-fg-secondary);
+ margin: 0;
+ padding-top: var(--canon-space-1_5);
+}
+
+.canon-SelectFieldError {
+ font-size: var(--canon-font-size-2);
+ font-weight: var(--canon-font-weight-regular);
+ color: var(--canon-fg-danger);
+ margin: 0;
+ padding-top: var(--canon-space-1_5);
+}
+
+.canon-SelectTrigger {
+ box-sizing: border-box;
+ border-radius: var(--canon-radius-3);
+ border: 1px solid var(--canon-border);
+ padding: 0 var(--canon-space-4);
+ background-color: var(--canon-bg-surface-1);
+ font-size: var(--canon-font-size-3);
+ font-family: var(--canon-font-regular);
+ font-weight: var(--canon-font-weight-regular);
+ color: var(--canon-fg-primary);
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out;
+ cursor: pointer;
+}
+
+.canon-SelectTrigger::placeholder {
+ color: var(--canon-fg-secondary);
+}
+
+.canon-SelectTrigger:hover {
+ border-color: var(--canon-border-hover);
+}
+
+.canon-SelectTrigger:focus-visible {
+ border-color: var(--canon-border-pressed);
+ outline: 0;
+}
+
+.canon-SelectTrigger[data-invalid] {
+ border-color: var(--canon-fg-danger);
+}
+.canon-SelectTrigger[data-invalid]:hover {
+ border-width: 2px;
+}
+
+.canon-SelectTrigger[data-invalid]:focus-visible {
+ border-width: 2px;
+}
+
+.canon-SelectTrigger[data-disabled] {
+ cursor: not-allowed;
+ border-color: var(--canon-border-disabled);
+ color: var(--canon-fg-disabled);
+}
+
+.canon-SelectTrigger--size-small,
+.canon-SelectItem--size-small {
+ height: 2rem;
+}
+
+.canon-SelectTrigger--size-medium,
+.canon-SelectItem--size-medium {
+ height: 3rem;
+}
+
+.canon-SelectIcon {
+ margin-left: var(--canon-space-5);
+ transition: transform 0.2s ease;
+}
+
+.canon-SelectTrigger[data-popup-open] .canon-SelectIcon {
+ transform: rotate(180deg);
+}
+
+.canon-SelectPopup {
+ box-sizing: border-box;
+ max-height: var(--available-height);
+ overflow-y: auto;
+ background-color: var(--canon-bg-surface-1);
+ border: 1px solid var(--canon-border);
+ border-radius: var(--canon-radius-3);
+ padding-block: var(--canon-space-1);
+ z-index: 1;
+ outline: 0;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+ transform-origin: var(--transform-origin);
+ transition: transform 150ms, opacity 150ms;
+}
+
+.canon-SelectPopup[data-starting-style],
+.canon-SelectPopup[data-ending-style] {
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.canon-SelectItem {
+ position: relative;
+ width: var(--anchor-width);
+ display: grid;
+ grid-template-areas: 'icon text';
+ grid-template-columns: 1rem 1fr;
+ align-items: center;
+ padding-block: var(--canon-space-2);
+ padding-inline: var(--canon-space-4);
+ color: var(--canon-fg-primary);
+ border-radius: var(--canon-radius-3);
+ cursor: pointer;
+ user-select: none;
+ font-size: var(--canon-font-size-3);
+ gap: var(--canon-space-2);
+ outline: none;
+}
+
+.canon-SelectItem[data-highlighted] {
+ z-index: 0;
+ position: relative;
+ color: var(--canon-fg-primary);
+}
+
+.canon-SelectItem[data-highlighted]::before {
+ content: '';
+ z-index: -1;
+ position: absolute;
+ inset-block: 0;
+ inset-inline: 0.25rem;
+ border-radius: 0.25rem;
+ background-color: var(--canon-bg-tint-hover);
+}
+
+.canon-SelectItem[data-disabled] {
+ cursor: not-allowed;
+ color: var(--canon-fg-disabled);
+}
+
+.canon-SelectItemIndicator {
+ grid-area: icon;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.canon-SelectItemText {
+ flex: 1;
+ grid-area: text;
+}
diff --git a/packages/canon/src/components/Select/Select.tsx b/packages/canon/src/components/Select/Select.tsx
new file mode 100644
index 0000000000..c6b8edc984
--- /dev/null
+++ b/packages/canon/src/components/Select/Select.tsx
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Select as SelectPrimitive } from '@base-ui-components/react/select';
+import { Field, Icon } from '@backstage/canon';
+import clsx from 'clsx';
+import './Select.styles.css';
+import { SelectProps } from './types';
+
+/**
+ * Select component
+ *
+ * @public
+ */
+export const Select = React.forwardRef(
+ (props, ref) => {
+ const {
+ className,
+ name,
+ label,
+ description,
+ value,
+ defaultValue,
+ placeholder,
+ onValueChange,
+ onOpenChange,
+ options,
+ size = 'medium',
+ disabled = false,
+ required = false,
+ } = props;
+ return (
+
+ {label && (
+ {label}
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {options?.map(option => (
+
+
+
+
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ );
+ },
+);
+
+Select.displayName = 'Select';
diff --git a/packages/canon/src/components/Select/index.ts b/packages/canon/src/components/Select/index.ts
new file mode 100644
index 0000000000..b0464dabd8
--- /dev/null
+++ b/packages/canon/src/components/Select/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './Select';
diff --git a/packages/canon/src/components/Select/types.ts b/packages/canon/src/components/Select/types.ts
new file mode 100644
index 0000000000..8e1407450d
--- /dev/null
+++ b/packages/canon/src/components/Select/types.ts
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Breakpoint } from '@backstage/canon';
+
+/** @public */
+export interface SelectProps {
+ /**
+ * The class name of the select field
+ */
+ className?: string;
+
+ /**
+ * The size of the select field
+ * @defaultValue 'medium'
+ */
+ size?: 'small' | 'medium' | Partial>;
+
+ /**
+ * The label of the select field
+ */
+ label?: string;
+
+ /**
+ * The description of the select field
+ */
+ description?: string;
+
+ /**
+ * The name of the select field
+ */
+ name: string;
+
+ /**
+ * Whether the select field should ignore user input
+ * @defaultValue false
+ */
+ disabled?: boolean;
+
+ /**
+ * Whether the select field is required
+ * @defaultValue false
+ */
+ required?: boolean;
+
+ /**
+ * The options of the select field
+ */
+ options?: Array<{ value: string; label: string; disabled?: boolean }>;
+
+ /**
+ * The current value of the select field
+ */
+ value?: string;
+
+ /**
+ * The default value of the select field, if nothing has been selected yet
+ */
+ defaultValue?: string;
+
+ /**
+ * A placeholder text to show if nothing has been selected and there's no default value
+ */
+ placeholder?: string;
+
+ /**
+ * Callback that is called when the value of the select field changes
+ */
+ onValueChange?: (value: string) => void;
+
+ /**
+ * Callbak that is called when the select field is opened or closed
+ */
+ onOpenChange?: (open: boolean) => void;
+}
diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css
index 8c77a0f58c..cd6517c6ed 100644
--- a/packages/canon/src/css/components.css
+++ b/packages/canon/src/css/components.css
@@ -31,3 +31,4 @@
@import '../components/Link/styles.css';
@import '../components/Tooltip/Tooltip.styles.css';
@import '../components/ScrollArea/ScrollArea.styles.css';
+@import '../components/Select/Select.styles.css';
diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts
index 3c9a28b44d..1f7841cfd5 100644
--- a/packages/canon/src/index.ts
+++ b/packages/canon/src/index.ts
@@ -42,6 +42,7 @@ export * from './components/Field';
export * from './components/Tooltip';
export * from './components/Menu';
export * from './components/ScrollArea';
+export * from './components/Select';
// Types
export * from './types';