diff --git a/.changeset/switch-label-position.md b/.changeset/switch-label-position.md
new file mode 100644
index 000000000..db72e507e
--- /dev/null
+++ b/.changeset/switch-label-position.md
@@ -0,0 +1,5 @@
+---
+'@launchpad-ui/components': minor
+---
+
+Add `labelPosition` to `Switch`. Pass `labelPosition="start"` to render a visible label before the track instead of after it, while keeping the label inside the switch so it stays clickable and remains the accessible name. Defaults to `"end"`, matching current behavior.
diff --git a/packages/components/__tests__/Switch.spec.tsx b/packages/components/__tests__/Switch.spec.tsx
index 2a9a74bdc..7d5ac4b97 100644
--- a/packages/components/__tests__/Switch.spec.tsx
+++ b/packages/components/__tests__/Switch.spec.tsx
@@ -25,6 +25,16 @@ describe('Switch', () => {
expect(screen.queryByText('On')).not.toBeInTheDocument();
});
+ it('renders a visible label after the track by default', () => {
+ render(Dark mode);
+ expect(screen.getByRole('switch', { name: /Dark mode/ }).closest('label')?.className).not.toContain('labelStart');
+ });
+
+ it('renders a visible label before the track when labelPosition is start', () => {
+ render(Dark mode);
+ expect(screen.getByRole('switch', { name: /Dark mode/ }).closest('label')?.className).toContain('labelStart');
+ });
+
it('renders with primary variant', () => {
render();
expect(screen.getByRole('switch')).toBeVisible();
diff --git a/packages/components/src/Switch.tsx b/packages/components/src/Switch.tsx
index bd2030b52..0a743e2b3 100644
--- a/packages/components/src/Switch.tsx
+++ b/packages/components/src/Switch.tsx
@@ -20,9 +20,14 @@ const switchStyles = cva(styles.switch, {
compact: {
true: styles.compact,
},
+ labelPosition: {
+ start: styles.labelStart,
+ end: '',
+ },
},
defaultVariants: {
variant: 'default',
+ labelPosition: 'end',
},
});
@@ -38,19 +43,21 @@ const SwitchContext = createContext>
* A switch allows a user to turn a setting on or off.
*
* Provide an accessible name via `children` (visible label) or `aria-label` (visually hidden label).
+ * A visible label renders after the track by default; pass `labelPosition="start"` to render it
+ * before the track while keeping it part of the switch's label and click target.
*
* https://react-spectrum.adobe.com/react-aria/Switch.html
*/
const Switch = ({ ref, ...props }: SwitchProps) => {
const [mergedProps, mergedRef] = useLPContextProps(props, ref, SwitchContext);
- const { switchLabels, variant } = mergedProps;
+ const { switchLabels, variant, labelPosition } = mergedProps;
const hideLabels = switchLabels === false ? true : undefined;
return (
- switchStyles({ ...renderProps, variant, compact: hideLabels, className }),
+ switchStyles({ ...renderProps, variant, labelPosition, compact: hideLabels, className }),
)}
>
{composeRenderProps(mergedProps.children, (children, { isSelected }) => (
diff --git a/packages/components/src/styles/Switch.module.css b/packages/components/src/styles/Switch.module.css
index b5b7457a8..d3412ec36 100644
--- a/packages/components/src/styles/Switch.module.css
+++ b/packages/components/src/styles/Switch.module.css
@@ -6,6 +6,12 @@
isolation: isolate;
}
+.switch.labelStart {
+ & .track {
+ order: 1;
+ }
+}
+
.track {
position: relative;
display: flex;
diff --git a/packages/components/stories/Switch.stories.tsx b/packages/components/stories/Switch.stories.tsx
index c2ec20b65..1984065e7 100644
--- a/packages/components/stories/Switch.stories.tsx
+++ b/packages/components/stories/Switch.stories.tsx
@@ -10,6 +10,10 @@ const meta: Meta = {
control: 'select',
options: ['default', 'primary'],
},
+ labelPosition: {
+ control: 'inline-radio',
+ options: ['start', 'end'],
+ },
},
parameters: {
figma: {
@@ -54,3 +58,11 @@ export const PrimaryHideLabels: Story = {
export const WithChildren: Story = {
args: { children: 'Dark mode' },
};
+
+export const LabelStart: Story = {
+ args: { children: 'Dark mode', labelPosition: 'start' },
+};
+
+export const LabelStartHideLabels: Story = {
+ args: { children: 'Dark mode', labelPosition: 'start', switchLabels: false },
+};