, keyof ComboboxOwnProps> {}
+
+/** @internal */
+export interface ComboboxInputOwnProps {
+ icon?: ComboboxOwnProps['icon'];
+ placeholder?: string;
+}
+
+/** @internal */
+export interface ComboboxListBoxOwnProps {
+ options?: ComboboxOwnProps['options'];
+}
+
+/** @internal */
+export type ComboboxListBoxItemOwnProps = {};
+
+/** @internal */
+export type ComboboxSectionOwnProps = {};
diff --git a/packages/ui/src/components/Container/Container.module.css b/packages/ui/src/components/Container/Container.module.css
index 1ebf52ccf3..9381126ee4 100644
--- a/packages/ui/src/components/Container/Container.module.css
+++ b/packages/ui/src/components/Container/Container.module.css
@@ -18,6 +18,8 @@
@layer components {
.bui-Container {
+ flex: 1;
+ width: 100%;
max-width: 120rem;
padding-inline: var(--bui-space-4);
margin-inline: auto;
diff --git a/packages/ui/src/components/DateRangePicker/DateRangePicker.module.css b/packages/ui/src/components/DateRangePicker/DateRangePicker.module.css
new file mode 100644
index 0000000000..4bb6b47a82
--- /dev/null
+++ b/packages/ui/src/components/DateRangePicker/DateRangePicker.module.css
@@ -0,0 +1,406 @@
+/*
+ * 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.
+ */
+
+@layer tokens, base, components, utilities;
+
+@layer components {
+ /* ============================================================
+ Root
+ ============================================================ */
+
+ .bui-DateRangePicker {
+ display: flex;
+ flex-direction: column;
+ font-family: var(--bui-font-regular);
+ width: 100%;
+ flex-shrink: 0;
+ }
+
+ /* ============================================================
+ Field group — custom container (not reusing any BUI field)
+ ============================================================ */
+
+ .bui-DateRangePickerGroup {
+ display: flex;
+ align-items: center;
+ background-color: var(--bui-bg-neutral-1);
+ border-radius: var(--bui-radius-2);
+ padding: 0 var(--bui-space-1) 0 var(--bui-space-3);
+ width: fit-content;
+ min-width: 280px;
+ max-width: 100%;
+ overflow: clip;
+ transition: box-shadow 0.2s ease-in-out;
+ cursor: text;
+
+ /* bg consumer — auto-increment background based on parent context */
+ &[data-on-bg='neutral-1'] {
+ background-color: var(--bui-bg-neutral-2);
+ }
+
+ &[data-on-bg='neutral-2'] {
+ background-color: var(--bui-bg-neutral-3);
+ }
+
+ &[data-on-bg='neutral-3'] {
+ background-color: var(--bui-bg-neutral-4);
+ }
+
+ &[data-focus-within] {
+ outline: none;
+ box-shadow: inset 0 0 0 1px var(--bui-ring);
+ }
+
+ &[data-invalid] {
+ box-shadow: inset 0 0 0 1px var(--bui-border-danger);
+ }
+
+ &[data-disabled] {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ /* Sizes */
+ &[data-size='small'] {
+ height: 2rem;
+ padding-inline-start: var(--bui-space-2);
+ }
+
+ &[data-size='medium'] {
+ height: 2.5rem;
+ min-width: 290px;
+ padding-inline-end: var(--bui-space-1);
+
+ & .bui-DateRangePickerButton {
+ width: 2rem;
+ height: 2rem;
+ }
+ }
+ }
+
+ /* ============================================================
+ Date fields wrapper — scrollable row of inputs + separator
+ ============================================================ */
+
+ .bui-DateRangePickerDateFields {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ width: fit-content;
+ overflow-x: auto;
+ overflow-y: clip;
+ scrollbar-width: none;
+ }
+
+ /* ============================================================
+ Date inputs
+ ============================================================ */
+
+ .bui-DateRangePickerDateInput {
+ display: inline-flex;
+ align-items: center;
+ width: unset;
+ min-width: unset;
+ padding: unset;
+ border: unset;
+ box-shadow: none;
+ background: none;
+ height: auto;
+
+ &[slot='end'] {
+ flex: 1;
+ }
+ }
+
+ /* ============================================================
+ Date segments (month, day, year literals and placeholders)
+ ============================================================ */
+
+ .bui-DateRangePickerSegment {
+ display: inline-block;
+ padding: var(--bui-space-0_5) var(--bui-space-1);
+ border-radius: var(--bui-radius-1);
+ font-size: var(--bui-font-size-3);
+ font-family: var(--bui-font-regular);
+ font-weight: var(--bui-font-weight-regular);
+ color: var(--bui-fg-primary);
+ caret-color: transparent;
+ outline: none;
+ font-variant-numeric: tabular-nums;
+
+ &[data-placeholder] {
+ color: var(--bui-fg-secondary);
+ }
+
+ &[data-type='literal'] {
+ color: var(--bui-fg-secondary);
+ padding: 0;
+ }
+
+ &[data-focused] {
+ background-color: var(--bui-bg-solid);
+ color: var(--bui-fg-solid);
+ border-radius: var(--bui-radius-1);
+
+ &[data-placeholder] {
+ color: var(--bui-fg-solid);
+ }
+ }
+
+ &[data-disabled] {
+ color: var(--bui-fg-disabled);
+ }
+ }
+
+ /* ============================================================
+ Separator between start and end
+ ============================================================ */
+
+ .bui-DateRangePickerSeparator {
+ color: var(--bui-fg-secondary);
+ font-size: var(--bui-font-size-3);
+ flex-shrink: 0;
+ user-select: none;
+ padding: 0 var(--bui-space-1);
+ }
+
+ /* ============================================================
+ Calendar trigger button
+ ============================================================ */
+
+ .bui-DateRangePickerButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 1.5rem;
+ height: 1.5rem;
+ padding: 0;
+ background: none;
+ border: none;
+ border-radius: var(--bui-radius-2);
+ color: var(--bui-fg-secondary);
+ cursor: pointer;
+ margin-left: auto;
+ transition: color 0.15s ease-in-out;
+ outline: none;
+
+ &[data-hovered] {
+ color: var(--bui-fg-primary);
+ }
+
+ &[data-focus-visible] {
+ box-shadow: inset 0 0 0 1px var(--bui-ring);
+ }
+
+ &[data-pressed] {
+ color: var(--bui-fg-primary);
+ }
+ }
+
+ /* ============================================================
+ Calendar (inside Popover)
+ ============================================================ */
+
+ .bui-DateRangePickerCalendar {
+ width: fit-content;
+ outline: none;
+ }
+
+ .bui-DateRangePickerCalendarHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: var(--bui-space-3);
+ }
+
+ .bui-DateRangePickerCalendarHeading {
+ font-size: var(--bui-font-size-3);
+ font-weight: var(--bui-font-weight-bold);
+ font-family: var(--bui-font-regular);
+ color: var(--bui-fg-primary);
+ flex: 1;
+ text-align: center;
+ }
+
+ .bui-DateRangePickerCalendarNavButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: var(--bui-space-8);
+ height: var(--bui-space-8);
+ background: none;
+ border: none;
+ border-radius: var(--bui-radius-2);
+ color: var(--bui-fg-secondary);
+ cursor: pointer;
+ transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
+
+ &[data-hovered] {
+ background-color: var(--bui-bg-neutral-2);
+ color: var(--bui-fg-primary);
+ }
+
+ &[data-focus-visible] {
+ outline: 2px solid var(--bui-ring);
+ outline-offset: 1px;
+ }
+
+ &[data-pressed] {
+ background-color: var(--bui-bg-neutral-2);
+ }
+ }
+
+ /* ============================================================
+ Calendar grid
+ ============================================================ */
+
+ .bui-DateRangePickerCalendarGrid {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0 2px;
+ }
+
+ .bui-DateRangePickerCalendarHeaderCell {
+ font-size: var(--bui-font-size-2);
+ font-weight: var(--bui-font-weight-regular);
+ color: var(--bui-fg-secondary);
+ text-align: center;
+ padding-bottom: var(--bui-space-2);
+ width: 40px;
+ }
+
+ /* ============================================================
+ Calendar cells
+
+ Technique: isolation: isolate creates a stacking context so
+ that ::before (range fill, z-index: -2) and ::after (circle,
+ z-index: -1) paint behind the cell's text content while still
+ rendering on top of the page background.
+ ============================================================ */
+
+ .bui-DateRangePickerCalendarCell {
+ isolation: isolate;
+ position: relative;
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ outline: none;
+ cursor: default;
+ font-size: var(--bui-font-size-3);
+ font-family: var(--bui-font-regular);
+ color: var(--bui-fg-primary);
+
+ &[data-outside-month] {
+ color: var(--bui-fg-disabled);
+ }
+
+ &[data-disabled] {
+ color: var(--bui-fg-disabled);
+ cursor: not-allowed;
+ }
+
+ &[data-unavailable] {
+ color: var(--bui-fg-disabled);
+ cursor: not-allowed;
+ text-decoration: line-through;
+ }
+
+ &[data-hovered]:not([data-disabled]):not([data-unavailable]):not(
+ [data-selected]
+ ) {
+ background-color: var(--bui-bg-neutral-2);
+ border-radius: var(--bui-radius-full);
+ }
+
+ &[data-focus-visible] {
+ outline: 2px solid var(--bui-ring);
+ outline-offset: -2px;
+ border-radius: var(--bui-radius-full);
+ }
+
+ /* Today marker */
+ &[data-today]:not([data-selected]):not([data-selection-start]):not(
+ [data-selection-end]
+ ) {
+ font-weight: var(--bui-font-weight-bold);
+ }
+
+ /* ── Range fill via ::before ─────────────────────────────── */
+
+ &[data-selected]::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: color-mix(in srgb, var(--bui-bg-solid) 15%, transparent);
+ z-index: -2;
+ }
+
+ /* Start cell: fill only the right half to connect forward */
+ &[data-selection-start]::before {
+ left: 50%;
+ }
+
+ /* End cell: fill only the left half to connect backward */
+ &[data-selection-end]::before {
+ right: 50%;
+ }
+
+ /* Same-day selection: no range fill needed */
+ &[data-selection-start][data-selection-end]::before {
+ display: none;
+ }
+
+ /* ── Solid circle for start / end via ::after ────────────── */
+
+ &[data-selection-start]::after,
+ &[data-selection-end]::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: var(--bui-bg-solid);
+ border-radius: var(--bui-radius-full);
+ z-index: -1;
+ }
+
+ /* Text color on top of the solid circle */
+ &[data-selection-start],
+ &[data-selection-end] {
+ color: var(--bui-fg-solid);
+ }
+ }
+
+ /* Round the left edge of ::before when the cell is the first in its row
+ (our class is on an inner element inside , hence td:first-child > *) */
+ .bui-DateRangePickerCalendarCell[data-selected]:not(
+ [data-selection-start]
+ ):is(td:first-child > *, [aria-disabled] + td > *)::before {
+ border-start-start-radius: var(--bui-radius-full);
+ border-end-start-radius: var(--bui-radius-full);
+ }
+
+ /* Round the right edge of ::before when the cell is the last in its row */
+ .bui-DateRangePickerCalendarCell[data-selected]:not([data-selection-end]):is(
+ td:last-child > *
+ )::before {
+ border-start-end-radius: var(--bui-radius-full);
+ border-end-end-radius: var(--bui-radius-full);
+ }
+}
diff --git a/packages/ui/src/components/DateRangePicker/DateRangePicker.stories.tsx b/packages/ui/src/components/DateRangePicker/DateRangePicker.stories.tsx
new file mode 100644
index 0000000000..ed3696ae83
--- /dev/null
+++ b/packages/ui/src/components/DateRangePicker/DateRangePicker.stories.tsx
@@ -0,0 +1,155 @@
+/*
+ * 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 preview from '../../../../../.storybook/preview';
+import { DateRangePicker } from './DateRangePicker';
+import {
+ parseDate,
+ today,
+ getLocalTimeZone,
+ isWeekend,
+} from '@internationalized/date';
+import { useLocale, Form } from 'react-aria-components';
+import { Button } from '../Button';
+
+const meta = preview.meta({
+ title: 'Backstage UI/DateRangePicker',
+ component: DateRangePicker,
+ args: {
+ style: { width: 360 },
+ },
+});
+
+export const Default = meta.story({
+ args: {},
+});
+
+export const WithLabel = meta.story({
+ args: {
+ label: 'Date range',
+ },
+});
+
+export const WithDescription = meta.story({
+ args: {
+ label: 'Date range',
+ description: 'Select a start and end date for your event.',
+ },
+});
+
+export const WithDefaultValue = meta.story({
+ args: {
+ label: 'Booking period',
+ defaultValue: {
+ start: parseDate('2025-02-03'),
+ end: parseDate('2025-02-14'),
+ },
+ },
+});
+
+export const Sizes = meta.story({
+ args: {
+ label: 'Date range',
+ },
+ render: args => (
+
+
+
+
+ ),
+});
+
+export const Required = meta.story({
+ args: {
+ label: 'Trip dates',
+ isRequired: true,
+ },
+ render: args => (
+
+ ),
+});
+
+export const Disabled = meta.story({
+ args: {
+ label: 'Date range',
+ isDisabled: true,
+ defaultValue: {
+ start: parseDate('2025-03-01'),
+ end: parseDate('2025-03-15'),
+ },
+ },
+});
+
+export const Invalid = meta.story({
+ args: {
+ label: 'Date range',
+ isInvalid: true,
+ errorMessage: 'The selected range is not available.',
+ defaultValue: {
+ start: parseDate('2025-04-01'),
+ end: parseDate('2025-04-10'),
+ },
+ },
+});
+
+export const WithMinMaxValue = meta.story({
+ args: {
+ label: 'Date range',
+ description: 'You can only select dates within the next 30 days.',
+ minValue: today(getLocalTimeZone()),
+ maxValue: today(getLocalTimeZone()).add({ days: 30 }),
+ },
+});
+
+/**
+ * Weekends are marked unavailable. Because `allowsNonContiguousRanges` is not
+ * set (defaults to false), the picker prevents the user from selecting any
+ * range that spans across an unavailable date — the selection snaps to avoid
+ * crossing a weekend.
+ */
+export const WithUnavailableDates = meta.story({
+ render: args => {
+ const { locale } = useLocale();
+ return (
+ isWeekend(date, locale)}
+ />
+ );
+ },
+});
diff --git a/packages/ui/src/components/DateRangePicker/DateRangePicker.tsx b/packages/ui/src/components/DateRangePicker/DateRangePicker.tsx
new file mode 100644
index 0000000000..6b6841e09c
--- /dev/null
+++ b/packages/ui/src/components/DateRangePicker/DateRangePicker.tsx
@@ -0,0 +1,81 @@
+/*
+ * 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 { forwardRef, useEffect } from 'react';
+import { DateRangePicker as AriaDateRangePicker } from 'react-aria-components';
+import { FieldLabel } from '../FieldLabel';
+import { FieldError } from '../FieldError';
+import { Popover } from '../Popover';
+import { DateRangePickerGroup } from './DateRangePickerGroup';
+import { DateRangePickerCalendar } from './DateRangePickerCalendar';
+import { useDefinition } from '../../hooks/useDefinition';
+import { DateRangePickerDefinition } from './definition';
+import type { DateRangePickerProps } from './types';
+
+/**
+ * A date range picker that combines two date fields and a calendar popover,
+ * allowing users to enter or select a date range with full keyboard and
+ * screen reader accessibility.
+ *
+ * @public
+ */
+export const DateRangePicker = forwardRef(
+ (props, ref) => {
+ const { ownProps, restProps, dataAttributes } = useDefinition(
+ DateRangePickerDefinition,
+ props,
+ );
+
+ const { classes, label, description, secondaryLabel } = ownProps;
+
+ const ariaLabel = restProps['aria-label'];
+ const ariaLabelledBy = restProps['aria-labelledby'];
+
+ useEffect(() => {
+ if (!label && !ariaLabel && !ariaLabelledBy) {
+ console.warn(
+ 'DateRangePicker requires either a visible label, aria-label, or aria-labelledby for accessibility',
+ );
+ }
+ }, [label, ariaLabel, ariaLabelledBy]);
+
+ const secondaryLabelText =
+ secondaryLabel || (restProps.isRequired ? 'Required' : null);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ },
+);
+
+DateRangePicker.displayName = 'DateRangePicker';
diff --git a/packages/ui/src/components/DateRangePicker/DateRangePickerCalendar.tsx b/packages/ui/src/components/DateRangePicker/DateRangePickerCalendar.tsx
new file mode 100644
index 0000000000..49e7675915
--- /dev/null
+++ b/packages/ui/src/components/DateRangePicker/DateRangePickerCalendar.tsx
@@ -0,0 +1,66 @@
+/*
+ * 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 {
+ RangeCalendar,
+ CalendarGrid,
+ CalendarGridHeader,
+ CalendarHeaderCell,
+ CalendarGridBody,
+ CalendarCell,
+ Heading,
+ Button,
+} from 'react-aria-components';
+import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react';
+import { useDefinition } from '../../hooks/useDefinition';
+import { DateRangePickerCalendarDefinition } from './definition';
+
+/**
+ * Calendar popover content for DateRangePicker — renders the RangeCalendar
+ * with navigation and a full calendar grid.
+ *
+ * @internal
+ */
+export const DateRangePickerCalendar = () => {
+ const { ownProps } = useDefinition(DateRangePickerCalendarDefinition, {});
+ const { classes } = ownProps;
+
+ return (
+
+
+
+
+ {day => (
+
+ {day}
+
+ )}
+
+
+ {date => }
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/DateRangePicker/DateRangePickerGroup.tsx b/packages/ui/src/components/DateRangePicker/DateRangePickerGroup.tsx
new file mode 100644
index 0000000000..3d177b556d
--- /dev/null
+++ b/packages/ui/src/components/DateRangePicker/DateRangePickerGroup.tsx
@@ -0,0 +1,61 @@
+/*
+ * 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 { Group, DateInput, DateSegment, Button } from 'react-aria-components';
+import { RiCalendarLine } from '@remixicon/react';
+import { useDefinition } from '../../hooks/useDefinition';
+import { DateRangePickerGroupDefinition } from './definition';
+
+/**
+ * Custom field group for DateRangePicker — renders two DateInput fields,
+ * a separator, and a calendar trigger button.
+ *
+ * @internal
+ */
+export const DateRangePickerGroup = ({ dataSize }: { dataSize?: string }) => {
+ const { ownProps, dataAttributes } = useDefinition(
+ DateRangePickerGroupDefinition,
+ {},
+ );
+ const { classes } = ownProps;
+
+ return (
+
+
+
+ {segment => (
+
+ )}
+
+
+ –
+
+
+ {segment => (
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/DateRangePicker/definition.ts b/packages/ui/src/components/DateRangePicker/definition.ts
new file mode 100644
index 0000000000..f6c72aedf8
--- /dev/null
+++ b/packages/ui/src/components/DateRangePicker/definition.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { defineComponent } from '../../hooks/useDefinition';
+import type { DateRangePickerOwnProps } from './types';
+import styles from './DateRangePicker.module.css';
+
+/**
+ * Component definition for DateRangePicker
+ * @public
+ */
+export const DateRangePickerDefinition =
+ defineComponent()({
+ styles,
+ classNames: {
+ root: 'bui-DateRangePicker',
+ },
+ propDefs: {
+ size: { dataAttribute: true, default: 'small' },
+ className: {},
+ label: {},
+ description: {},
+ secondaryLabel: {},
+ },
+ });
+
+/**
+ * Component definition for DateRangePickerGroup
+ * @internal
+ */
+export const DateRangePickerGroupDefinition = defineComponent<
+ Record
+>()({
+ styles,
+ classNames: {
+ root: 'bui-DateRangePickerGroup',
+ dateFields: 'bui-DateRangePickerDateFields',
+ dateInput: 'bui-DateRangePickerDateInput',
+ segment: 'bui-DateRangePickerSegment',
+ separator: 'bui-DateRangePickerSeparator',
+ button: 'bui-DateRangePickerButton',
+ },
+ bg: 'consumer',
+ propDefs: {},
+});
+
+/**
+ * Component definition for DateRangePickerCalendar
+ * @internal
+ */
+export const DateRangePickerCalendarDefinition = defineComponent<
+ Record
+>()({
+ styles,
+ classNames: {
+ root: 'bui-DateRangePickerCalendar',
+ header: 'bui-DateRangePickerCalendarHeader',
+ heading: 'bui-DateRangePickerCalendarHeading',
+ navButton: 'bui-DateRangePickerCalendarNavButton',
+ grid: 'bui-DateRangePickerCalendarGrid',
+ gridHeader: 'bui-DateRangePickerCalendarGridHeader',
+ headerCell: 'bui-DateRangePickerCalendarHeaderCell',
+ gridBody: 'bui-DateRangePickerCalendarGridBody',
+ cell: 'bui-DateRangePickerCalendarCell',
+ },
+ propDefs: {},
+});
diff --git a/packages/ui/src/components/DateRangePicker/index.ts b/packages/ui/src/components/DateRangePicker/index.ts
new file mode 100644
index 0000000000..89364291c8
--- /dev/null
+++ b/packages/ui/src/components/DateRangePicker/index.ts
@@ -0,0 +1,19 @@
+/*
+ * 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.
+ */
+
+export * from './DateRangePicker';
+export * from './types';
+export { DateRangePickerDefinition } from './definition';
diff --git a/packages/ui/src/components/DateRangePicker/types.ts b/packages/ui/src/components/DateRangePicker/types.ts
new file mode 100644
index 0000000000..2cc3c53214
--- /dev/null
+++ b/packages/ui/src/components/DateRangePicker/types.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 type { DateRangePickerProps as AriaDateRangePickerProps } from 'react-aria-components';
+import type { DateValue } from '@internationalized/date';
+import type { Breakpoint } from '../../types';
+import type { FieldLabelProps } from '../FieldLabel/types';
+
+/** @public */
+export type DateRangePickerOwnProps = {
+ /**
+ * The size of the date range picker
+ * @defaultValue 'small'
+ */
+ size?: 'small' | 'medium' | Partial>;
+
+ className?: string;
+
+ label?: FieldLabelProps['label'];
+ description?: FieldLabelProps['description'];
+ secondaryLabel?: FieldLabelProps['secondaryLabel'];
+};
+
+/** @public */
+export interface DateRangePickerProps
+ extends Omit, 'className' | 'children'>,
+ DateRangePickerOwnProps {}
diff --git a/packages/ui/src/components/Flex/Flex.module.css b/packages/ui/src/components/Flex/Flex.module.css
index 499326b9ea..0d44fbd12f 100644
--- a/packages/ui/src/components/Flex/Flex.module.css
+++ b/packages/ui/src/components/Flex/Flex.module.css
@@ -23,28 +23,4 @@
/* This helps when using `truncate` on text inside a flex container */
min-width: 0;
}
-
- .bui-Flex[data-bg='neutral-1'] {
- background-color: var(--bui-bg-neutral-1);
- }
-
- .bui-Flex[data-bg='neutral-2'] {
- background-color: var(--bui-bg-neutral-2);
- }
-
- .bui-Flex[data-bg='neutral-3'] {
- background-color: var(--bui-bg-neutral-3);
- }
-
- .bui-Flex[data-bg='danger'] {
- background-color: var(--bui-bg-danger);
- }
-
- .bui-Flex[data-bg='warning'] {
- background-color: var(--bui-bg-warning);
- }
-
- .bui-Flex[data-bg='success'] {
- background-color: var(--bui-bg-success);
- }
}
diff --git a/packages/ui/src/components/Flex/Flex.stories.tsx b/packages/ui/src/components/Flex/Flex.stories.tsx
index 91d4088004..b392b6e731 100644
--- a/packages/ui/src/components/Flex/Flex.stories.tsx
+++ b/packages/ui/src/components/Flex/Flex.stories.tsx
@@ -16,7 +16,9 @@
import preview from '../../../../../.storybook/preview';
import { Flex } from './Flex';
import { Text } from '../Text';
-import { Box } from '../Box';
+import { Box, BoxProps } from '../Box';
+import { Card, CardHeader, CardBody, CardFooter } from '../Card';
+import { Grid } from '../Grid';
const meta = preview.meta({
title: 'Backstage UI/Flex',
@@ -41,10 +43,9 @@ const meta = preview.meta({
const DecorativeBox = ({
width = '48px',
height = '48px',
-}: {
- width?: string;
- height?: string;
-}) => {
+ style,
+ ...props
+}: Omit) => {
const diagonalStripePattern = (() => {
const svg = `
@@ -58,9 +59,11 @@ const DecorativeBox = ({
return (
,
+ Card: props => (
+
+
+ Header
+
+
+
+ This is the first paragraph of a long body text that
+ demonstrates how the Card component handles extensive content.
+ The card should adjust accordingly to display all the text
+ properly while maintaining its structure.
+
+
+ Here's a second paragraph that adds more content to our card
+ body. Having multiple paragraphs helps to visualize how spacing
+ works within the card component.
+
+
+ This third paragraph continues to add more text to ensure we
+ have a proper demonstration of a card with significant content.
+ This makes it easier to test scrolling behavior and overall
+ layout when content exceeds the initial view.
+
+
+
+ Footer
+
+
+ ),
+ Grid: props => (
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ Flex: props => (
+
+
+
+
+
+ ),
+ },
+ },
+ grow: {
+ control: 'radio',
+ options: [undefined, 0, 1, false, true],
+ },
+ shrink: {
+ control: 'radio',
+ options: [undefined, 0, 1, false, true],
+ },
+ basis: {
+ control: 'radio',
+ options: [undefined, '0%', '25%', '50%', '100%', 100, '250px', 'auto'],
+ },
+ },
+ render: ({ component: Component, ...args }) => {
+ return (
+
+
+
+
+
+
+
+ );
+ },
+});
+
export const ResponsiveGap = meta.story({
args: {
gap: { xs: '4', md: '8', lg: '12' },
diff --git a/packages/ui/src/components/Flex/definition.ts b/packages/ui/src/components/Flex/definition.ts
index 802464c4e1..1bc9c54217 100644
--- a/packages/ui/src/components/Flex/definition.ts
+++ b/packages/ui/src/components/Flex/definition.ts
@@ -53,5 +53,8 @@ export const FlexDefinition = defineComponent()({
'align',
'justify',
'direction',
+ 'grow',
+ 'shrink',
+ 'basis',
],
});
diff --git a/packages/ui/src/components/Flex/types.ts b/packages/ui/src/components/Flex/types.ts
index 1c7f887e62..5b295e1825 100644
--- a/packages/ui/src/components/Flex/types.ts
+++ b/packages/ui/src/components/Flex/types.ts
@@ -15,7 +15,13 @@
*/
import type { ReactNode, CSSProperties } from 'react';
-import type { Responsive, Space, SpaceProps, ProviderBg } from '../../types';
+import type {
+ Responsive,
+ Space,
+ SpaceProps,
+ ProviderBg,
+ FlexItemProps,
+} from '../../types';
/** @public */
export type FlexOwnProps = {
@@ -28,6 +34,7 @@ export type FlexOwnProps = {
/** @public */
export interface FlexProps
extends SpaceProps,
+ FlexItemProps,
FlexOwnProps,
Omit, 'children'> {
gap?: Responsive;
diff --git a/packages/ui/src/components/Grid/Grid.module.css b/packages/ui/src/components/Grid/Grid.module.css
index 8fdaf15c3b..ff98e9337f 100644
--- a/packages/ui/src/components/Grid/Grid.module.css
+++ b/packages/ui/src/components/Grid/Grid.module.css
@@ -20,34 +20,4 @@
.bui-Grid {
display: grid;
}
-
- .bui-Grid[data-bg='neutral-1'],
- .bui-GridItem[data-bg='neutral-1'] {
- background-color: var(--bui-bg-neutral-1);
- }
-
- .bui-Grid[data-bg='neutral-2'],
- .bui-GridItem[data-bg='neutral-2'] {
- background-color: var(--bui-bg-neutral-2);
- }
-
- .bui-Grid[data-bg='neutral-3'],
- .bui-GridItem[data-bg='neutral-3'] {
- background-color: var(--bui-bg-neutral-3);
- }
-
- .bui-Grid[data-bg='danger'],
- .bui-GridItem[data-bg='danger'] {
- background-color: var(--bui-bg-danger);
- }
-
- .bui-Grid[data-bg='warning'],
- .bui-GridItem[data-bg='warning'] {
- background-color: var(--bui-bg-warning);
- }
-
- .bui-Grid[data-bg='success'],
- .bui-GridItem[data-bg='success'] {
- background-color: var(--bui-bg-success);
- }
}
diff --git a/packages/ui/src/components/Grid/definition.ts b/packages/ui/src/components/Grid/definition.ts
index 606b286b3c..2d83e9db6a 100644
--- a/packages/ui/src/components/Grid/definition.ts
+++ b/packages/ui/src/components/Grid/definition.ts
@@ -51,6 +51,9 @@ export const GridDefinition = defineComponent()({
'pt',
'px',
'py',
+ 'grow',
+ 'shrink',
+ 'basis',
],
});
diff --git a/packages/ui/src/components/Grid/types.ts b/packages/ui/src/components/Grid/types.ts
index 6ad5d564c6..90699c59d2 100644
--- a/packages/ui/src/components/Grid/types.ts
+++ b/packages/ui/src/components/Grid/types.ts
@@ -21,6 +21,7 @@ import type {
Responsive,
Columns,
ProviderBg,
+ FlexItemProps,
} from '../../types';
/** @public */
@@ -34,6 +35,7 @@ export type GridOwnProps = {
/** @public */
export interface GridProps
extends SpaceProps,
+ FlexItemProps,
GridOwnProps,
Omit, 'children'> {
columns?: Responsive;
diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css
index d74d3eeb50..60db85cb25 100644
--- a/packages/ui/src/components/Header/Header.module.css
+++ b/packages/ui/src/components/Header/Header.module.css
@@ -17,17 +17,73 @@
@layer tokens, base, components, utilities;
@layer components {
- .bui-Header {
+ .bui-HeaderBottom {
display: flex;
flex-direction: column;
- gap: var(--bui-space-1);
- margin-top: var(--bui-space-6);
+ gap: var(--bui-space-3);
+ margin-bottom: var(--bui-space-5);
+ }
+
+ .bui-HeaderTop,
+ .bui-HeaderContent,
+ .bui-HeaderBottom {
+ width: 100%;
+ padding-inline: var(--bui-space-5);
+ box-sizing: border-box;
+ }
+
+ .bui-HeaderStickySentinel {
+ height: 1px;
+ margin-bottom: -1px;
+ pointer-events: none;
}
.bui-HeaderContent {
display: flex;
flex-direction: row;
justify-content: space-between;
+ gap: var(--bui-space-3);
+ padding-top: var(--bui-space-3);
+ padding-bottom: var(--bui-space-3);
+ }
+
+ .bui-HeaderContent[data-has-tags] {
+ padding-top: var(--bui-space-2);
+ }
+
+ .bui-HeaderContent[data-sticky] {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ background-color: var(--bui-bg-app);
+
+ &[data-on-bg='neutral-1'] {
+ background-color: var(--bui-bg-neutral-1);
+ }
+
+ &[data-on-bg='neutral-2'] {
+ background-color: var(--bui-bg-neutral-2);
+ }
+
+ &[data-on-bg='neutral-3'] {
+ background-color: var(--bui-bg-neutral-3);
+ }
+ }
+
+ .bui-HeaderContent[data-sticky]::after {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 1px;
+ content: '';
+ background-color: var(--bui-border-1);
+ opacity: 0;
+ transition: opacity 200ms ease;
+ }
+
+ .bui-HeaderContent[data-stuck]::after {
+ opacity: 1;
}
.bui-HeaderTabsWrapper {
@@ -39,12 +95,124 @@
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
+ flex-shrink: 0;
}
- .bui-HeaderBreadcrumbs {
+ .bui-HeaderTitleStack {
+ position: relative;
+ flex: 1 1 auto;
+ height: 1lh;
+ min-width: 0;
+ font-size: var(--bui-font-size-6);
+ line-height: 140%;
+ overflow: hidden;
+ }
+
+ .bui-HeaderBreadcrumbs,
+ .bui-HeaderBreadcrumbsSmall {
+ position: absolute;
+ inset-inline: 0;
+ top: 50%;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--bui-space-2);
+ min-width: 0;
+ transform: translateY(-50%);
+ }
+
+ .bui-HeaderBreadcrumbsSmall {
+ gap: var(--bui-space-1);
+ }
+
+ .bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink,
+ .bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall {
+ max-width: 240px;
+ overflow: hidden;
+ font-family: var(--bui-font-regular);
+ font-weight: var(--bui-font-weight-bold);
+ line-height: inherit;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink {
+ font-size: inherit;
+ }
+
+ .bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall {
+ font-size: var(--bui-font-size-4);
+ }
+
+ .bui-HeaderBreadcrumbSeparator {
+ flex-shrink: 0;
+ }
+
+ .bui-HeaderTitle,
+ .bui-HeaderTitleSmall {
+ margin: 0;
+ padding: 0;
+ flex: 1 1 auto;
+ min-width: 0;
+ font-family: var(--bui-font-regular);
+ font-weight: var(--bui-font-weight-bold);
+ line-height: inherit;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .bui-HeaderTitle {
+ font-size: inherit;
+ }
+
+ .bui-HeaderTitleSmall {
+ font-size: var(--bui-font-size-4);
+ }
+
+ .bui-HeaderTags {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--bui-space-2);
+ flex-wrap: wrap;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ .bui-HeaderTag {
+ display: flex;
+ align-items: center;
+ gap: var(--bui-space-2);
+ }
+
+ .bui-HeaderTag + .bui-HeaderTag::before {
+ content: '';
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background-color: var(--bui-fg-secondary);
+ flex-shrink: 0;
+ }
+
+ .bui-HeaderMetaRow {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--bui-space-5);
+ flex-wrap: wrap;
+ margin: 0;
+ }
+
+ .bui-HeaderMetaItem {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: var(--bui-space-2);
+
+ dd {
+ margin: 0;
+ }
}
}
diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx
index c30534a993..9cc715e471 100644
--- a/packages/ui/src/components/Header/Header.stories.tsx
+++ b/packages/ui/src/components/Header/Header.stories.tsx
@@ -17,11 +17,14 @@
import preview from '../../../../../.storybook/preview';
import type { StoryFn } from '@storybook/react-vite';
import { Header } from './Header';
+import { HeaderMetadataUsers } from './HeaderMetadataUsers';
+import { HeaderMetadataStatus } from './HeaderMetadataStatus';
import type { HeaderNavTabItem } from './types';
import { MemoryRouter } from 'react-router-dom';
import { BUIProvider } from '../../provider';
import { Button, ButtonIcon, MenuTrigger, Menu, MenuItem } from '../../';
import { RiMore2Line } from '@remixicon/react';
+import { Container } from '../Container';
const meta = preview.meta({
title: 'Backstage UI/Header',
@@ -152,16 +155,208 @@ export const WithLongBreadcrumbs = meta.story({
},
});
-export const WithEverything = meta.story({
+export const WithDescription = meta.story({
decorators: [withRouter],
args: {
...Default.input.args,
- tabs,
- customActions: Custom action ,
- breadcrumbs: [{ label: 'Home', href: '/' }],
+ description:
+ 'This is a description of the page. It can include [inline links](https://backstage.io).',
},
});
+export const WithTags = meta.story({
+ decorators: [withRouter],
+ args: {
+ ...Default.input.args,
+ tags: [
+ { label: 'TypeScript' },
+ { label: 'Platform', href: '/platform' },
+ { label: 'Gold' },
+ ],
+ },
+});
+
+export const WithMetadata = meta.story({
+ decorators: [withRouter],
+ args: {
+ ...Default.input.args,
+ metadata: [
+ { label: 'Owner', value: 'platform-team' },
+ { label: 'Type', value: 'website' },
+ ],
+ },
+});
+
+const users = {
+ giles: {
+ name: 'Giles Peyton-Nicoll',
+ src: 'https://i.pravatar.cc/150?u=giles',
+ href: '/users/giles',
+ },
+ alice: {
+ name: 'Alice Johnson',
+ src: 'https://i.pravatar.cc/150?u=alicej',
+ href: '/users/alice',
+ },
+ bob: {
+ name: 'Bob Smith',
+ src: 'https://i.pravatar.cc/150?u=bob',
+ href: '/users/bob',
+ },
+ carol: {
+ name: 'Carol Williams',
+ src: 'https://i.pravatar.cc/150?u=carol',
+ href: '/users/carol',
+ },
+};
+
+export const WithMetadataUsers = meta.story({
+ decorators: [withRouter],
+ render: () => (
+ ,
+ },
+ {
+ label: 'Contributors',
+ value: (
+
+ ),
+ },
+ ]}
+ />
+ ),
+});
+
+export const WithMetadataUsersNoLinks = meta.story({
+ decorators: [withRouter],
+ render: () => (
+
+ ),
+ },
+ {
+ label: 'Contributors',
+ value: (
+
+ ),
+ },
+ ]}
+ />
+ ),
+});
+
+export const WithMetadataStatus = meta.story({
+ decorators: [withRouter],
+ render: () => (
+ ,
+ },
+ {
+ label: 'Build',
+ value: (
+
+ ),
+ },
+ {
+ label: 'Coverage',
+ value: ,
+ },
+ ]}
+ />
+ ),
+});
+
+export const WithDescriptionTagsAndMetadata = meta.story({
+ decorators: [withRouter],
+ render: () => (
+ ,
+ },
+ {
+ label: 'Contributors',
+ value: (
+
+ ),
+ },
+ { label: 'Type', value: 'website' },
+ { label: 'Tier', value: 'gold' },
+ ]}
+ />
+ ),
+});
+
+export const WithEverything = meta.story({
+ decorators: [withRouter],
+ render: () => (
+ Custom action}
+ breadcrumbs={[{ label: 'Home', href: '/' }]}
+ description="This is a description of the page. It can include [inline links](https://backstage.io)."
+ tags={[
+ { label: 'TypeScript' },
+ { label: 'Platform', href: '/platform' },
+ { label: 'Gold' },
+ ]}
+ metadata={[
+ { label: 'Type', value: 'website' },
+ {
+ label: 'Owner',
+ value: ,
+ },
+ {
+ label: 'Contributors',
+ value: (
+
+ ),
+ },
+ ]}
+ />
+ ),
+});
+
const groupedTabs: HeaderNavTabItem[] = [
{ id: 'overview', label: 'Overview', href: '/overview' },
{
@@ -199,3 +394,127 @@ export const WithExplicitActiveTab = meta.story({
activeTabId: 'campaigns',
},
});
+
+export const NonSticky = meta.story({
+ decorators: [withRouter],
+ render: () => (
+ <>
+