- {(label || showValueLabel) && (
-
-
- {showValueLabel && (
-
- {({ state }) => {
- const values = state.values;
- if (values.length === 2) {
- return `${formatValue(values[0])} - ${formatValue(
- values[1],
- )}`;
- }
- return formatValue(values[0]);
- }}
-
- )}
-
- )}
-
- {({ state }) => {
- const start = state.getThumbPercent(0);
- const end = state.getThumbPercent(1);
- const rangePercent = (end - start) * 100;
- const isVertical = state.orientation === 'vertical';
- const trackFillStyle = isVertical
- ? {
- bottom: `${start * 100}%`,
- height: `${rangePercent}%`,
- }
- : {
- left: `${start * 100}%`,
- width: `${rangePercent}%`,
- };
- return (
- <>
-
-
-
- >
- );
- }}
-
-
-
- );
- },
-);
-
-RangeSlider.displayName = 'RangeSlider';
diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.module.css b/packages/ui/src/components/Slider/Slider.module.css
similarity index 76%
rename from packages/ui/src/components/RangeSlider/RangeSlider.module.css
rename to packages/ui/src/components/Slider/Slider.module.css
index 209f823a19..a84be7a306 100644
--- a/packages/ui/src/components/RangeSlider/RangeSlider.module.css
+++ b/packages/ui/src/components/Slider/Slider.module.css
@@ -17,39 +17,34 @@
@layer tokens, base, components, utilities;
@layer components {
- .bui-RangeSlider {
+ .bui-Slider {
display: flex;
flex-direction: column;
gap: var(--bui-space-2);
width: 100%;
color: var(--bui-fg-primary);
- &[data-disabled] {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
&[data-orientation='vertical'] {
height: 200px;
width: auto;
}
}
- .bui-RangeSliderHeader {
+ .bui-SliderHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--bui-space-3);
}
- .bui-RangeSliderOutput {
+ .bui-SliderOutput {
font-size: var(--bui-font-size-2);
font-weight: var(--bui-font-weight-medium);
color: var(--bui-fg-secondary);
white-space: nowrap;
}
- .bui-RangeSliderTrack {
+ .bui-SliderTrack {
position: relative;
height: 4px;
width: 100%;
@@ -68,7 +63,7 @@
}
}
- .bui-RangeSliderTrackFill {
+ .bui-SliderTrackFill {
position: absolute;
top: 0;
height: 100%;
@@ -86,7 +81,7 @@
}
}
- .bui-RangeSliderThumb {
+ .bui-SliderThumb {
width: 20px;
height: 20px;
border-radius: 50%;
@@ -94,7 +89,11 @@
border: 2px solid var(--bui-bg-solid);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
cursor: grab;
- transition: all 200ms;
+ transition: transform 200ms;
+
+ /* Fix: Ensure thumb is vertically centered on track */
+ top: 50%;
+ transform: translateY(-50%);
&[data-focus-visible] {
outline: 2px solid var(--bui-ring);
@@ -105,17 +104,32 @@
cursor: grabbing;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
- }
-
- &[data-disabled] {
- cursor: not-allowed;
- background: var(--bui-bg-neutral-3);
- border-color: var(--bui-bg-neutral-3);
+ transform: translateY(-50%) scale(1.1);
}
/* Hover effect */
&:hover:not([data-disabled]) {
- transform: scale(1.1);
+ transform: translateY(-50%) scale(1.1);
+ }
+ }
+
+ /* Improved disabled state */
+ .bui-Slider[data-disabled] {
+ opacity: 0.6;
+ cursor: not-allowed;
+
+ .bui-SliderTrack {
+ background: var(--bui-bg-neutral-2);
+ }
+
+ .bui-SliderTrackFill {
+ background: var(--bui-bg-neutral-4);
+ }
+
+ .bui-SliderThumb {
+ cursor: not-allowed;
+ background: var(--bui-bg-neutral-4);
+ border-color: var(--bui-border-neutral);
}
}
}
diff --git a/packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx b/packages/ui/src/components/Slider/Slider.stories.tsx
similarity index 64%
rename from packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx
rename to packages/ui/src/components/Slider/Slider.stories.tsx
index 6040f72dff..b03f45412c 100644
--- a/packages/ui/src/components/RangeSlider/RangeSlider.stories.tsx
+++ b/packages/ui/src/components/Slider/Slider.stories.tsx
@@ -14,18 +14,34 @@
* limitations under the License.
*/
import preview from '../../../../../.storybook/preview';
-import { RangeSlider } from './RangeSlider';
+import { Slider } from './Slider';
const meta = preview.meta({
- title: 'Backstage UI/RangeSlider',
- component: RangeSlider,
+ title: 'Backstage UI/Slider',
+ component: Slider,
});
-export const Default = meta.story({
+export const SingleThumb = meta.story({
+ args: {
+ label: 'Volume',
+ defaultValue: 50,
+ },
+});
+
+export const SingleThumbWithRange = meta.story({
+ args: {
+ label: 'Brightness',
+ minValue: 0,
+ maxValue: 100,
+ defaultValue: 75,
+ step: 5,
+ },
+});
+
+export const RangeSlider = meta.story({
args: {
label: 'Price Range',
defaultValue: [25, 75],
- showValueLabel: true,
},
});
@@ -36,7 +52,6 @@ export const WithCustomRange = meta.story({
maxValue: 40,
defaultValue: [0, 20],
step: 5,
- showValueLabel: true,
},
});
@@ -47,8 +62,11 @@ export const WithFormattedValues = meta.story({
maxValue: 10000,
defaultValue: [2000, 8000],
step: 100,
- showValueLabel: true,
- formatValue: (value: number) => `$${value.toLocaleString()}`,
+ formatOptions: {
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: 0,
+ },
},
});
@@ -59,7 +77,6 @@ export const WithDescription = meta.story({
minValue: 0,
maxValue: 100,
defaultValue: [18, 65],
- showValueLabel: true,
},
});
@@ -68,7 +85,6 @@ export const Required = meta.story({
label: 'Score Range',
defaultValue: [20, 80],
isRequired: true,
- showValueLabel: true,
},
});
@@ -77,46 +93,29 @@ export const Disabled = meta.story({
label: 'Disabled Range',
defaultValue: [30, 70],
isDisabled: true,
- showValueLabel: true,
},
});
export const WithSteps = meta.story({
args: {
- label: 'Rating Range',
+ label: 'Rating',
minValue: 0,
maxValue: 5,
step: 0.5,
- defaultValue: [1.5, 4],
- showValueLabel: true,
- formatValue: (value: number) => `${value} ★`,
+ defaultValue: 3.5,
},
});
-export const SmallRange = meta.story({
+export const Percentage = meta.story({
args: {
- label: 'Month Range',
- minValue: 1,
- maxValue: 12,
- defaultValue: [3, 9],
- step: 1,
- showValueLabel: true,
- formatValue: (value: number) => {
- const months = [
- 'Jan',
- 'Feb',
- 'Mar',
- 'Apr',
- 'May',
- 'Jun',
- 'Jul',
- 'Aug',
- 'Sep',
- 'Oct',
- 'Nov',
- 'Dec',
- ];
- return months[value - 1] || '';
+ label: 'Completion',
+ minValue: 0,
+ maxValue: 100,
+ defaultValue: 65,
+ formatOptions: {
+ style: 'percent',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
},
},
});
diff --git a/packages/ui/src/components/Slider/Slider.tsx b/packages/ui/src/components/Slider/Slider.tsx
new file mode 100644
index 0000000000..3bfb407504
--- /dev/null
+++ b/packages/ui/src/components/Slider/Slider.tsx
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2026 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 {
+ Slider as AriaSlider,
+ SliderTrack,
+ SliderThumb,
+ SliderOutput,
+} from 'react-aria-components';
+import clsx from 'clsx';
+import { FieldLabel } from '../FieldLabel';
+import { FieldError } from '../FieldError';
+import type { SliderProps } from './types';
+import { useDefinition } from '../../hooks/useDefinition';
+import { SliderDefinition } from './definition';
+import styles from './Slider.module.css';
+
+function SliderImpl