diff --git a/.changeset/wild-donkeys-sneeze.md b/.changeset/wild-donkeys-sneeze.md
new file mode 100644
index 0000000000..5e2c4474c5
--- /dev/null
+++ b/.changeset/wild-donkeys-sneeze.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': patch
+---
+
+Added new VisuallyHidden component for hiding content visually while keeping it accessible to screen readers.
diff --git a/docs-ui/src/content/components/visually-hidden.mdx b/docs-ui/src/content/components/visually-hidden.mdx
new file mode 100644
index 0000000000..26a609b245
--- /dev/null
+++ b/docs-ui/src/content/components/visually-hidden.mdx
@@ -0,0 +1,51 @@
+import { PropsTable } from '@/components/PropsTable';
+import { Snippet } from '@/components/Snippet';
+import { CodeBlock } from '@/components/CodeBlock';
+import { VisuallyHiddenSnippet } from '@/snippets/stories-snippets';
+import {
+ visuallyHiddenPropDefs,
+ visuallyHiddenUsageSnippet,
+ visuallyHiddenDefaultSnippet,
+ visuallyHiddenExampleUsageSnippet,
+} from './visually-hidden.props';
+import { PageTitle } from '@/components/PageTitle';
+import { Theming } from '@/components/Theming';
+import { ChangelogComponent } from '@/components/ChangelogComponent';
+
+
+
+}
+ code={visuallyHiddenDefaultSnippet}
+/>
+
+## Usage
+
+
+
+## API reference
+
+
+
+## Examples
+
+### Example Usage
+
+Here's an example of providing screen reader context for a list of links in a footer.
+
+}
+ code={visuallyHiddenExampleUsageSnippet}
+ open
+/>
+
+
+
+
diff --git a/docs-ui/src/content/components/visually-hidden.props.ts b/docs-ui/src/content/components/visually-hidden.props.ts
new file mode 100644
index 0000000000..18a1c503d1
--- /dev/null
+++ b/docs-ui/src/content/components/visually-hidden.props.ts
@@ -0,0 +1,47 @@
+import {
+ classNamePropDefs,
+ stylePropDefs,
+ type PropDef,
+} from '@/utils/propDefs';
+
+export const visuallyHiddenPropDefs: Record = {
+ children: {
+ type: 'enum',
+ values: ['ReactNode'],
+ responsive: false,
+ },
+ ...classNamePropDefs,
+ ...stylePropDefs,
+};
+
+export const visuallyHiddenUsageSnippet = `import { VisuallyHidden } from '@backstage/ui';
+
+
+ This content is visually hidden but accessible to screen readers
+`;
+
+export const visuallyHiddenDefaultSnippet = `
+
+ This text is followed by a paragraph that is visually hidden but
+ accessible to screen readers. Try using a screen reader to hear it, or
+ inspect the DOM to see it's there.
+
+
+ This content is visually hidden but accessible to screen readers
+
+`;
+
+export const visuallyHiddenExampleUsageSnippet = `
+
+ Footer links
+
+
+ About us
+
+
+ Jobs
+
+
+ Terms and Conditions
+
+`;
diff --git a/docs-ui/src/snippets/stories-snippets.tsx b/docs-ui/src/snippets/stories-snippets.tsx
index 53b1f21ac4..3a687929e8 100644
--- a/docs-ui/src/snippets/stories-snippets.tsx
+++ b/docs-ui/src/snippets/stories-snippets.tsx
@@ -29,6 +29,7 @@ import * as HeaderPageStories from '../../../packages/ui/src/components/HeaderPa
import * as TableStories from '../../../packages/ui/src/components/Table/Table.stories';
import * as TagGroupStories from '../../../packages/ui/src/components/TagGroup/TagGroup.stories';
import * as PasswordFieldStories from '../../../packages/ui/src/components/PasswordField/PasswordField.stories';
+import * as VisuallyHiddenStories from '../../../packages/ui/src/components/visuallyHidden/VisuallyHidden.stories';
// Helper function to create snippet components
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -74,3 +75,6 @@ export const HeaderSnippet = createSnippetComponent(HeaderStories);
export const HeaderPageSnippet = createSnippetComponent(HeaderPageStories);
export const TableSnippet = createSnippetComponent(TableStories);
export const TagGroupSnippet = createSnippetComponent(TagGroupStories);
+export const VisuallyHiddenSnippet = createSnippetComponent(
+ VisuallyHiddenStories,
+);
diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts
index bd5b7b1ba1..18d75d31c8 100644
--- a/docs-ui/src/utils/data.ts
+++ b/docs-ui/src/utils/data.ts
@@ -182,6 +182,11 @@ export const components: Page[] = [
slug: 'tooltip',
status: 'alpha',
},
+ {
+ title: 'VisuallyHidden',
+ slug: 'visually-hidden',
+ status: 'alpha',
+ },
];
export type ScreenSize = {
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index 91d81e29e3..e0b8132f7f 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -743,6 +743,11 @@ export const componentDefinitions: {
readonly arrow: 'bui-TooltipArrow';
};
};
+ readonly VisuallyHidden: {
+ readonly classNames: {
+ readonly root: 'bui-VisuallyHidden';
+ };
+ };
};
// @public (undocumented)
@@ -1534,4 +1539,13 @@ export interface UtilityProps extends SpaceProps {
// (undocumented)
rowSpan?: Responsive;
}
+
+// @public
+export const VisuallyHidden: (props: VisuallyHiddenProps) => JSX_2.Element;
+
+// @public
+export interface VisuallyHiddenProps extends ComponentProps<'div'> {
+ // (undocumented)
+ children?: React.ReactNode;
+}
```
diff --git a/packages/ui/src/components/VisuallyHidden/VisuallyHidden.module.css b/packages/ui/src/components/VisuallyHidden/VisuallyHidden.module.css
new file mode 100644
index 0000000000..dbb7500405
--- /dev/null
+++ b/packages/ui/src/components/VisuallyHidden/VisuallyHidden.module.css
@@ -0,0 +1,32 @@
+/*
+ * 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.
+ */
+
+@layer tokens, base, components, utilities;
+
+@layer components {
+ .bui-VisuallyHidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(100%);
+ white-space: nowrap;
+ border: 0;
+ }
+}
diff --git a/packages/ui/src/components/VisuallyHidden/VisuallyHidden.stories.tsx b/packages/ui/src/components/VisuallyHidden/VisuallyHidden.stories.tsx
new file mode 100644
index 0000000000..9706edfc9b
--- /dev/null
+++ b/packages/ui/src/components/VisuallyHidden/VisuallyHidden.stories.tsx
@@ -0,0 +1,73 @@
+/*
+ * 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 type { Meta, StoryObj } from '@storybook/react-vite';
+import { VisuallyHidden } from './VisuallyHidden';
+import { Text } from '../Text';
+import { Flex } from '../Flex';
+
+const meta = {
+ title: 'Backstage UI/VisuallyHidden',
+ component: VisuallyHidden,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Visually hides content while keeping it accessible to screen readers. Commonly used for descriptive labels, and other screen-reader-only content.',
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+ This text is followed by a paragraph that is visually hidden but
+ accessible to screen readers. Try using a screen reader to hear it, or
+ inspect the DOM to see it's there.
+
+
+ This content is visually hidden but accessible to screen readers
+
+
+ ),
+};
+
+export const ExampleUsage: Story = {
+ render: () => (
+
+
+ Footer links
+
+
+ About us
+
+
+ Jobs
+
+
+ Terms and Conditions
+
+
+ (Screen readers hear: "Footer links" followed by the list of links)
+
+
+ ),
+};
diff --git a/packages/ui/src/components/VisuallyHidden/VisuallyHidden.tsx b/packages/ui/src/components/VisuallyHidden/VisuallyHidden.tsx
new file mode 100644
index 0000000000..305f8fc424
--- /dev/null
+++ b/packages/ui/src/components/VisuallyHidden/VisuallyHidden.tsx
@@ -0,0 +1,41 @@
+/*
+ * 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 { useStyles } from '../../hooks/useStyles';
+import { VisuallyHiddenProps } from './types';
+import styles from './VisuallyHidden.module.css';
+import clsx from 'clsx';
+
+/**
+ * Visually hides content while keeping it accessible to screen readers.
+ * Useful for descriptive labels and other screen-reader-only content.
+ *
+ * Note: This component is for content that should ALWAYS remain visually hidden.
+ * For skip links that become visible on focus, use a different approach.
+ *
+ * @public
+ */
+export const VisuallyHidden = (props: VisuallyHiddenProps) => {
+ const { classNames, cleanedProps } = useStyles('VisuallyHidden', props);
+ const { className, ...rest } = cleanedProps;
+
+ return (
+
+ );
+};
diff --git a/packages/ui/src/components/VisuallyHidden/index.ts b/packages/ui/src/components/VisuallyHidden/index.ts
new file mode 100644
index 0000000000..6946ec00e5
--- /dev/null
+++ b/packages/ui/src/components/VisuallyHidden/index.ts
@@ -0,0 +1,18 @@
+/*
+ * 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 { VisuallyHidden } from './VisuallyHidden';
+export type { VisuallyHiddenProps } from './types';
diff --git a/packages/ui/src/components/VisuallyHidden/types.ts b/packages/ui/src/components/VisuallyHidden/types.ts
new file mode 100644
index 0000000000..88951d7ee2
--- /dev/null
+++ b/packages/ui/src/components/VisuallyHidden/types.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 { ComponentProps } from 'react';
+
+/**
+ * Properties for {@link VisuallyHidden}
+ *
+ * @public
+ */
+export interface VisuallyHiddenProps extends ComponentProps<'div'> {
+ children?: React.ReactNode;
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 4484a858d5..1bb10cfbd1 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -52,6 +52,7 @@ export * from './components/Link';
export * from './components/Select';
export * from './components/Skeleton';
export * from './components/Switch';
+export * from './components/VisuallyHidden';
// Types
export * from './types';
diff --git a/packages/ui/src/utils/componentDefinitions.ts b/packages/ui/src/utils/componentDefinitions.ts
index d2d1450f59..4f914cd1f9 100644
--- a/packages/ui/src/utils/componentDefinitions.ts
+++ b/packages/ui/src/utils/componentDefinitions.ts
@@ -395,4 +395,9 @@ export const componentDefinitions = {
arrow: 'bui-TooltipArrow',
},
},
+ VisuallyHidden: {
+ classNames: {
+ root: 'bui-VisuallyHidden',
+ },
+ },
} as const satisfies Record;