diff --git a/.changeset/ui-date-range-picker.md b/.changeset/ui-date-range-picker.md
new file mode 100644
index 0000000000..e69da8d7f9
--- /dev/null
+++ b/.changeset/ui-date-range-picker.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': patch
+---
+
+Added new `DateRangePicker` component — combines two date fields and a calendar popover for selecting a date range, built on React Aria with full keyboard and screen reader accessibility. Uses BUI design tokens throughout, including auto-incremented backgrounds via the bg consumer pattern.
diff --git a/docs-ui/src/app/components/date-range-picker/components.tsx b/docs-ui/src/app/components/date-range-picker/components.tsx
new file mode 100644
index 0000000000..3da4ec0561
--- /dev/null
+++ b/docs-ui/src/app/components/date-range-picker/components.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { DateRangePicker } from '../../../../../packages/ui/src/components/DateRangePicker/DateRangePicker';
+import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex';
+import { parseDate } from '@internationalized/date';
+
+export const WithLabel = () => {
+ return ;
+};
+
+export const Sizes = () => {
+ return (
+
+
+
+
+ );
+};
+
+export const WithDefaultValue = () => {
+ return (
+
+ );
+};
+
+export const Disabled = () => {
+ return (
+
+ );
+};
diff --git a/docs-ui/src/app/components/date-range-picker/page.mdx b/docs-ui/src/app/components/date-range-picker/page.mdx
new file mode 100644
index 0000000000..0299d23822
--- /dev/null
+++ b/docs-ui/src/app/components/date-range-picker/page.mdx
@@ -0,0 +1,85 @@
+import { PropsTable } from '@/components/PropsTable';
+import { Snippet } from '@/components/Snippet';
+import { dateRangePickerPropDefs } from './props-definition';
+import {
+ dateRangePickerUsageSnippet,
+ withLabelSnippet,
+ sizesSnippet,
+ withDefaultValueSnippet,
+ disabledSnippet,
+} from './snippets';
+import { WithLabel, Sizes, WithDefaultValue, Disabled } from './components';
+import { PageTitle } from '@/components/PageTitle';
+import { Theming } from '@/components/Theming';
+import { DateRangePickerDefinition } from '../../../utils/definitions';
+import { ChangelogComponent } from '@/components/ChangelogComponent';
+import { CodeBlock } from '@/components/CodeBlock';
+import { ReactAriaLink } from '@/components/ReactAriaLink';
+
+export const reactAriaUrls = {
+ dateRangePicker: 'https://react-aria.adobe.com/DateRangePicker',
+};
+
+
+
+}
+ code={withLabelSnippet}
+/>
+
+## Usage
+
+
+
+## API reference
+
+
+
+
+
+## Examples
+
+### Sizes
+
+}
+ code={sizesSnippet}
+ layout="side-by-side"
+/>
+
+### With default value
+
+}
+ code={withDefaultValueSnippet}
+ layout="side-by-side"
+/>
+
+### Disabled
+
+}
+ code={disabledSnippet}
+ layout="side-by-side"
+/>
+
+
+
+
diff --git a/docs-ui/src/app/components/date-range-picker/props-definition.tsx b/docs-ui/src/app/components/date-range-picker/props-definition.tsx
new file mode 100644
index 0000000000..b7200f868e
--- /dev/null
+++ b/docs-ui/src/app/components/date-range-picker/props-definition.tsx
@@ -0,0 +1,103 @@
+import {
+ classNamePropDefs,
+ stylePropDefs,
+ type PropDef,
+} from '@/utils/propDefs';
+import { Chip } from '@/components/Chip';
+
+export const dateRangePickerPropDefs: Record = {
+ size: {
+ type: 'enum',
+ values: ['small', 'medium'],
+ default: 'small',
+ responsive: true,
+ description: (
+ <>
+ Visual size of the picker. Use small for dense layouts,{' '}
+ medium for prominent fields.
+ >
+ ),
+ },
+ label: {
+ type: 'string',
+ description: 'Visible label displayed above the picker.',
+ },
+ secondaryLabel: {
+ type: 'string',
+ description: (
+ <>
+ Secondary text shown next to the label. If not provided and isRequired
+ is true, displays Required.
+ >
+ ),
+ },
+ description: {
+ type: 'string',
+ description: 'Help text displayed below the label.',
+ },
+ value: {
+ type: 'enum',
+ values: ['RangeValue'],
+ description: 'Controlled value of the date range.',
+ },
+ defaultValue: {
+ type: 'enum',
+ values: ['RangeValue'],
+ description: 'Default value for uncontrolled usage.',
+ },
+ onChange: {
+ type: 'enum',
+ values: ['(value: RangeValue | null) => void'],
+ description: 'Handler called when the selected range changes.',
+ },
+ granularity: {
+ type: 'enum',
+ values: ['day', 'hour', 'minute', 'second'],
+ default: 'day',
+ description:
+ 'Smallest unit displayed. Defaults to "day" for dates and "minute" for times.',
+ },
+ minValue: {
+ type: 'enum',
+ values: ['DateValue'],
+ description: 'Minimum allowed date. Dates before this are disabled.',
+ },
+ maxValue: {
+ type: 'enum',
+ values: ['DateValue'],
+ description: 'Maximum allowed date. Dates after this are disabled.',
+ },
+ isDateUnavailable: {
+ type: 'enum',
+ values: ['(date: DateValue) => boolean'],
+ description:
+ 'Callback invoked for each calendar date. Return true to mark a date as unavailable.',
+ },
+ allowsNonContiguousRanges: {
+ type: 'boolean',
+ description:
+ 'When combined with isDateUnavailable, allows selecting ranges that contain unavailable dates.',
+ },
+ startName: {
+ type: 'string',
+ description: 'Form field name for the start date, submitted as ISO 8601.',
+ },
+ endName: {
+ type: 'string',
+ description: 'Form field name for the end date, submitted as ISO 8601.',
+ },
+ isRequired: {
+ type: 'boolean',
+ description: 'Whether the field is required for form submission.',
+ },
+ isDisabled: {
+ type: 'boolean',
+ description: 'Whether the picker is disabled.',
+ },
+ isReadOnly: {
+ type: 'boolean',
+ description: 'Whether the picker is read-only.',
+ },
+ ...classNamePropDefs,
+ ...stylePropDefs,
+};
diff --git a/docs-ui/src/app/components/date-range-picker/snippets.ts b/docs-ui/src/app/components/date-range-picker/snippets.ts
new file mode 100644
index 0000000000..3faab626ed
--- /dev/null
+++ b/docs-ui/src/app/components/date-range-picker/snippets.ts
@@ -0,0 +1,28 @@
+export const dateRangePickerUsageSnippet = `import { DateRangePicker } from '@backstage/ui';
+
+`;
+
+export const withLabelSnippet = ``;
+
+export const sizesSnippet = `
+
+
+`;
+
+export const withDefaultValueSnippet = `import { parseDate } from '@internationalized/date';
+
+`;
+
+export const disabledSnippet = ``;
diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts
index 2a16065930..55f10eba95 100644
--- a/docs-ui/src/utils/data.ts
+++ b/docs-ui/src/utils/data.ts
@@ -53,6 +53,10 @@ export const components: Page[] = [
title: 'Container',
slug: 'container',
},
+ {
+ title: 'DateRangePicker',
+ slug: 'date-range-picker',
+ },
{
title: 'Dialog',
slug: 'dialog',
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 1b17bc8744..dce45e578b 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -47,6 +47,7 @@
},
"dependencies": {
"@backstage/version-bridge": "workspace:^",
+ "@internationalized/date": "^3.12.0",
"@remixicon/react": "^4.6.0",
"@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1",
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index c48382dede..f158f5dade 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -14,6 +14,8 @@ import type { ComponentProps } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
import type { ComponentPropsWithRef } from 'react';
import type { CSSProperties } from 'react';
+import type { DateRangePickerProps as DateRangePickerProps_2 } from 'react-aria-components';
+import type { DateValue } from '@internationalized/date';
import type { DialogTriggerProps as DialogTriggerProps_2 } from 'react-aria-components';
import type { DisclosureGroupProps } from 'react-aria-components';
import type { DisclosurePanelProps } from 'react-aria-components';
@@ -1103,6 +1105,45 @@ export interface CursorResponse {
totalCount?: number;
}
+// @public
+export const DateRangePicker: ForwardRefExoticComponent<
+ DateRangePickerProps & RefAttributes
+>;
+
+// @public
+export const DateRangePickerDefinition: {
+ readonly styles: {
+ readonly [key: string]: string;
+ };
+ readonly classNames: {
+ readonly root: 'bui-DateRangePicker';
+ };
+ readonly propDefs: {
+ readonly size: {
+ readonly dataAttribute: true;
+ readonly default: 'small';
+ };
+ readonly className: {};
+ readonly label: {};
+ readonly description: {};
+ readonly secondaryLabel: {};
+ };
+};
+
+// @public (undocumented)
+export type DateRangePickerOwnProps = {
+ size?: 'small' | 'medium' | Partial>;
+ className?: string;
+ label?: FieldLabelProps['label'];
+ description?: FieldLabelProps['description'];
+ secondaryLabel?: FieldLabelProps['secondaryLabel'];
+};
+
+// @public (undocumented)
+export interface DateRangePickerProps
+ extends Omit, 'className' | 'children'>,
+ DateRangePickerOwnProps {}
+
// @public
export const Dialog: ForwardRefExoticComponent<
DialogProps & RefAttributes
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 (
+
+
+
+
+ );
+};
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/definitions.ts b/packages/ui/src/definitions.ts
index 1d16c67f2a..85599a3b23 100644
--- a/packages/ui/src/definitions.ts
+++ b/packages/ui/src/definitions.ts
@@ -36,6 +36,7 @@ export { CardDefinition } from './components/Card/definition';
export { CheckboxDefinition } from './components/Checkbox/definition';
export { CheckboxGroupDefinition } from './components/CheckboxGroup/definition';
export { ContainerDefinition } from './components/Container/definition';
+export { DateRangePickerDefinition } from './components/DateRangePicker/definition';
export { DialogDefinition } from './components/Dialog/definition';
export { FieldErrorDefinition } from './components/FieldError/definition';
export { FieldLabelDefinition } from './components/FieldLabel/definition';
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 708e816349..a1584c1718 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -34,6 +34,7 @@ export * from './components/Avatar';
export * from './components/Badge';
export * from './components/Button';
export * from './components/Card';
+export * from './components/DateRangePicker';
export * from './components/Dialog';
export * from './components/FieldLabel';
export * from './components/PluginHeader';
diff --git a/yarn.lock b/yarn.lock
index 6802845bac..68ba5bc315 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7973,6 +7973,7 @@ __metadata:
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/version-bridge": "workspace:^"
+ "@internationalized/date": "npm:^3.12.0"
"@remixicon/react": "npm:^4.6.0"
"@storybook/react-vite": "npm:^10.3.3"
"@tanstack/react-table": "npm:^8.21.3"
@@ -10127,7 +10128,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@internationalized/date@npm:^3.12.1":
+"@internationalized/date@npm:^3.12.0, @internationalized/date@npm:^3.12.1":
version: 3.12.1
resolution: "@internationalized/date@npm:3.12.1"
dependencies:
@@ -11885,8 +11886,8 @@ __metadata:
linkType: hard
"@mswjs/interceptors@npm:^0.39.1":
- version: 0.39.8
- resolution: "@mswjs/interceptors@npm:0.39.8"
+ version: 0.39.6
+ resolution: "@mswjs/interceptors@npm:0.39.6"
dependencies:
"@open-draft/deferred-promise": "npm:^2.2.0"
"@open-draft/logger": "npm:^0.3.0"
@@ -11894,7 +11895,7 @@ __metadata:
is-node-process: "npm:^1.2.0"
outvariant: "npm:^1.4.3"
strict-event-emitter: "npm:^0.5.1"
- checksum: 10/d92546cf9bf670ddb927c53f5fa19f0554b7475a264ead4e1ae2339874f4312fe4ada5d42588f27eea3577bee29fa8f46889d398f0e7ecb3f7a4c1d3e0b71bdc
+ checksum: 10/c87d3edf08353bde825c87b151b24d538070540ab419206cef1774c932e888af0f920183182fb7c94c3eee42068da5a0a5855853fded8514f33c870921ef37ec
languageName: node
linkType: hard
@@ -27474,14 +27475,14 @@ __metadata:
linkType: hard
"dompurify@npm:^3.1.7, dompurify@npm:^3.3.2":
- version: 3.4.0
- resolution: "dompurify@npm:3.4.0"
+ version: 3.3.3
+ resolution: "dompurify@npm:3.3.3"
dependencies:
"@types/trusted-types": "npm:^2.0.7"
dependenciesMeta:
"@types/trusted-types":
optional: true
- checksum: 10/ead40b78ec51cd451f2c74fada4233ee0afeafdbab54af2f4a4bd5d4d138ac04d0d85140e79f533803ecfd1c3758edc1176087039c1e7217824f9794a9d34d2c
+ checksum: 10/4cc9c539ed7136d46c6577613b8e20871c2b6165db01dfbd2a3c11c75f9e339c496ac6519a1c3190115def8cadae3720bef0417fc43fa28802c7407bab174da9
languageName: node
linkType: hard