Improve Link component styles

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-01-30 17:49:06 +00:00
parent e2b342a7e6
commit 110fec026b
17 changed files with 200 additions and 7490 deletions
+49
View File
@@ -0,0 +1,49 @@
---
'@backstage/ui': minor
---
Added new status foreground tokens and improved Link component styling
**New Status Tokens:**
Added dedicated tokens for status colors that distinguish between usage on status backgrounds vs. standalone usage:
- `--bui-fg-danger-on-bg` / `--bui-fg-danger`
- `--bui-fg-warning-on-bg` / `--bui-fg-warning`
- `--bui-fg-success-on-bg` / `--bui-fg-success`
- `--bui-fg-info-on-bg` / `--bui-fg-info`
The `-on-bg` variants are designed for text on colored backgrounds, while the base variants are for standalone status indicators with improved visibility and contrast.
**Migration:**
If you're using status foreground colors on colored backgrounds, update to the new `-on-bg` tokens:
```diff
.error-badge {
- color: var(--bui-fg-danger);
+ color: var(--bui-fg-danger-on-bg);
background: var(--bui-bg-danger);
}
```
For standalone status indicators (icons, badges, text), continue using the base tokens which now have updated values for better visibility.
**Link Component Updates:**
1. **New `standalone` prop**: Links now have a `standalone` variant that removes the default underline (shows only on hover)
2. **New `info` color**: Added support for `color="info"`
3. **Improved default underline styling**: Links now show underlines by default with refined styling using `color-mix` for better visual hierarchy
```tsx
// Default link - shows underline by default
<Link href="/">Sign up</Link>
// Standalone link - underline only on hover
<Link href="/" standalone>Sign up</Link>
// Info color link
<Link href="/" color="info">Learn more</Link>
```
**Affected components:** Link
+30 -10
View File
@@ -7,7 +7,9 @@ import { MemoryRouter } from 'react-router-dom';
export const Default = () => {
return (
<MemoryRouter>
<Link href="/">Sign up for Backstage</Link>
<Link href="/" variant="body-large">
Sign up for Backstage
</Link>
</MemoryRouter>
);
};
@@ -15,7 +17,7 @@ export const Default = () => {
export const ExternalLink = () => {
return (
<MemoryRouter>
<Link href="https://backstage.io" target="_blank">
<Link href="https://backstage.io" target="_blank" variant="body-large">
Sign up for Backstage
</Link>
</MemoryRouter>
@@ -58,22 +60,25 @@ export const AllVariants = () => {
export const AllColors = () => {
return (
<MemoryRouter>
<Flex gap="4" direction="column">
<Link href="#" color="primary">
<Flex gap="2" direction="column">
<Link href="#" color="primary" variant="body-large">
Primary
</Link>
<Link href="#" color="secondary">
<Link href="#" color="secondary" variant="body-large">
Secondary
</Link>
<Link href="#" color="danger">
<Link href="#" color="danger" variant="body-large">
Danger
</Link>
<Link href="#" color="warning">
<Link href="#" color="warning" variant="body-large">
Warning
</Link>
<Link href="#" color="success">
<Link href="#" color="success" variant="body-large">
Success
</Link>
<Link href="#" color="info" variant="body-large">
Info
</Link>
</Flex>
</MemoryRouter>
);
@@ -83,13 +88,28 @@ export const Weight = () => {
return (
<MemoryRouter>
<Flex gap="4">
<Link href="#" weight="regular">
<Link href="#" weight="regular" variant="body-large">
Regular
</Link>
<Link href="#" weight="bold">
<Link href="#" weight="bold" variant="body-large">
Bold
</Link>
</Flex>
</MemoryRouter>
);
};
export const Standalone = () => {
return (
<MemoryRouter>
<Flex gap="4">
<Link href="#" variant="body-large">
Default link
</Link>
<Link href="#" variant="body-large" standalone>
Standalone link
</Link>
</Flex>
</MemoryRouter>
);
};
+10 -1
View File
@@ -10,6 +10,7 @@ import {
allVariantsSnippet,
allColorsSnippet,
weightSnippet,
standaloneSnippet,
} from './snippets';
import {
Default,
@@ -17,6 +18,7 @@ import {
AllVariants,
AllColors,
Weight,
Standalone,
} from './components';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
@@ -55,6 +57,7 @@ Use `target="_blank"` to open links in a new tab.
py={4}
preview={<ExternalLink />}
code={externalLinkSnippet}
layout="side-by-side"
/>
### Variants
@@ -79,7 +82,13 @@ Status colors for contextual links.
### Weight
<Snippet align="center" py={4} preview={<Weight />} code={weightSnippet} />
<Snippet align="center" py={4} preview={<Weight />} code={weightSnippet} layout="side-by-side" />
### Standalone
Use `standalone` to remove the underline by default. The underline will appear on hover.
<Snippet align="center" py={4} preview={<Standalone />} code={standaloneSnippet} layout="side-by-side" />
<Theming definition={LinkDefinition} />
@@ -66,6 +66,12 @@ export const linkPropDefs: Record<string, PropDef> = {
type: 'boolean',
description:
'Truncates text with ellipsis when it overflows its container.',
default: 'false',
},
standalone: {
type: 'boolean',
description: 'Removes underline by default. Underline appears on hover.',
default: 'false',
},
...childrenPropDefs,
...classNamePropDefs,
+7 -1
View File
@@ -4,7 +4,7 @@ export const linkUsageSnippet = `import { Link } from '@backstage/ui';
export const defaultSnippet = `<Link href="/">Sign up for Backstage</Link>`;
export const externalLinkSnippet = `<Link href="https://backstage.io" target="_blank">
export const externalLinkSnippet = `<Link href="#" target="_blank">
Sign up for Backstage
</Link>`;
@@ -25,9 +25,15 @@ export const allColorsSnippet = `<Flex gap="4" direction="column">
<Link href="#" color="danger">Danger</Link>
<Link href="#" color="warning">Warning</Link>
<Link href="#" color="success">Success</Link>
<Link href="#" color="info">Info</Link>
</Flex>`;
export const weightSnippet = `<Flex gap="4">
<Link href="#" weight="regular">Regular</Link>
<Link href="#" weight="bold">Bold</Link>
</Flex>`;
export const standaloneSnippet = `<Flex gap="4">
<Link href="#">Default link</Link>
<Link href="#" standalone>Standalone link</Link>
</Flex>`;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16 -8
View File
@@ -88,10 +88,14 @@
--bui-fg-solid-disabled: #98a8bc;
--bui-fg-tint: #1f5493;
--bui-fg-tint-disabled: var(--bui-gray-5);
--bui-fg-danger: #991919;
--bui-fg-warning: #92310a;
--bui-fg-success: #116932;
--bui-fg-info: #173da6;
--bui-fg-danger-on-bg: #991919;
--bui-fg-warning-on-bg: #92310a;
--bui-fg-success-on-bg: #116932;
--bui-fg-info-on-bg: #173da6;
--bui-fg-danger: #ec3b18;
--bui-fg-warning: #ef7a32;
--bui-fg-success: #1ed760;
--bui-fg-info: #0d74ce;
--bui-border: #0000001a;
--bui-border-hover: #0003;
--bui-border-pressed: #0006;
@@ -153,10 +157,14 @@
--bui-fg-solid-disabled: #6191cc;
--bui-fg-tint: #9cc9ff;
--bui-fg-tint-disabled: var(--bui-gray-5);
--bui-fg-danger: #fca5a5;
--bui-fg-warning: #fdba74;
--bui-fg-success: #86efac;
--bui-fg-info: #a3cfff;
--bui-fg-danger-on-bg: #fca5a5;
--bui-fg-warning-on-bg: #fdba74;
--bui-fg-success-on-bg: #86efac;
--bui-fg-info-on-bg: #a3cfff;
--bui-fg-danger: #ff5a30;
--bui-fg-warning: #ffa057;
--bui-fg-success: #1ed760;
--bui-fg-info: #70b8ff;
--bui-border: #ffffff1f;
--bui-border-hover: #fff6;
--bui-border-pressed: #ffffff80;
+4 -1
View File
@@ -1115,6 +1115,7 @@ export const LinkDefinition: {
'success',
];
readonly truncate: readonly [true, false];
readonly standalone: readonly [true, false];
};
};
@@ -1128,6 +1129,8 @@ export interface LinkProps extends LinkProps_2 {
| TextColorStatus
| Partial<Record<Breakpoint, TextColors | TextColorStatus>>;
// (undocumented)
standalone?: boolean;
// (undocumented)
title?: string;
// (undocumented)
truncate?: boolean;
@@ -1906,7 +1909,7 @@ export { Text_2 as Text };
export type TextColors = 'primary' | 'secondary';
// @public (undocumented)
export type TextColorStatus = 'danger' | 'warning' | 'success';
export type TextColorStatus = 'danger' | 'warning' | 'success' | 'info';
// @public
export const TextDefinition: {
@@ -36,22 +36,22 @@
.bui-Alert[data-status='info'] {
--alert-bg: var(--bui-bg-info);
--alert-fg: var(--bui-fg-info);
--alert-fg: var(--bui-fg-info-on-bg);
}
.bui-Alert[data-status='success'] {
--alert-bg: var(--bui-bg-success);
--alert-fg: var(--bui-fg-success);
--alert-fg: var(--bui-fg-success-on-bg);
}
.bui-Alert[data-status='warning'] {
--alert-bg: var(--bui-bg-warning);
--alert-fg: var(--bui-fg-warning);
--alert-fg: var(--bui-fg-warning-on-bg);
}
.bui-Alert[data-status='danger'] {
--alert-bg: var(--bui-bg-danger);
--alert-fg: var(--bui-fg-danger);
--alert-fg: var(--bui-fg-danger-on-bg);
}
.bui-AlertIcon {
@@ -22,9 +22,14 @@
padding: 0;
margin: 0;
cursor: pointer;
text-decoration-line: none;
display: inline-block;
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: min(2px, max(1px, 0.05em));
text-underline-offset: calc(0.025em + 2px);
text-decoration-color: color-mix(in srgb, currentColor 30%, transparent);
&:hover {
text-decoration-line: underline;
text-decoration-style: solid;
@@ -102,9 +107,25 @@
color: var(--bui-fg-success);
}
.bui-Link[data-color='info'] {
color: var(--bui-fg-info);
}
.bui-Link[data-truncate] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bui-Link[data-standalone] {
text-decoration-line: none;
&:hover {
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: min(2px, max(1px, 0.05em));
text-underline-offset: calc(0.025em + 2px);
text-decoration-color: color-mix(in srgb, currentColor 30%, transparent);
}
}
}
@@ -102,6 +102,12 @@ export const AllColors = meta.story({
color="success"
children="I am success"
/>
<Link
href="https://ui.backstage.io"
variant="title-small"
color="info"
children="I am info"
/>
</Flex>
),
});
@@ -235,6 +241,25 @@ export const Truncate = meta.story({
},
});
export const Standalone = meta.story({
args: {
href: '/',
children: 'Standalone link (no underline by default)',
standalone: true,
},
});
export const StandaloneComparison = meta.story({
render: () => (
<Flex gap="4" direction="column">
<Text>Default link (underline by default):</Text>
<Link href="/" children="Sign up for Backstage" />
<Text>Standalone link (underline on hover only):</Text>
<Link href="/" standalone children="Sign up for Backstage" />
</Flex>
),
});
export const Responsive = meta.story({
args: {
...Default.input.args,
@@ -244,27 +269,3 @@ export const Responsive = meta.story({
},
},
});
export const Playground = meta.story({
args: {
...Default.input.args,
},
render: args => (
<Flex gap="4" direction="column">
<Text>Title X Small</Text>
<Link variant="title-x-small" style={{ maxWidth: '600px' }} {...args} />
<Text>Body X Small</Text>
<Link variant="body-x-small" style={{ maxWidth: '600px' }} {...args} />
<Text>Body Small</Text>
<Link variant="body-small" style={{ maxWidth: '600px' }} {...args} />
<Text>Body Medium</Text>
<Link variant="body-medium" style={{ maxWidth: '600px' }} {...args} />
<Text>Body Large</Text>
<Link variant="body-large" style={{ maxWidth: '600px' }} {...args} />
<Text>Title Small</Text>
<Link variant="title-small" style={{ maxWidth: '600px' }} {...args} />
<Text>Title Medium</Text>
<Link variant="title-medium" style={{ maxWidth: '600px' }} {...args} />
</Flex>
),
});
+1
View File
@@ -44,6 +44,7 @@ const LinkInternal = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
weight,
color,
truncate,
standalone,
slot,
...restProps
} = cleanedProps;
@@ -29,5 +29,6 @@ export const LinkDefinition = {
weight: ['regular', 'bold'] as const,
color: ['primary', 'secondary', 'danger', 'warning', 'success'] as const,
truncate: [true, false] as const,
standalone: [true, false] as const,
},
} as const satisfies ComponentDefinition;
+1
View File
@@ -33,6 +33,7 @@ export interface LinkProps extends AriaLinkProps {
| TextColorStatus
| Partial<Record<Breakpoint, TextColors | TextColorStatus>>;
truncate?: boolean;
standalone?: boolean;
// This is used to set the title attribute on the link
title?: string;
+21 -8
View File
@@ -118,6 +118,7 @@
--bui-bg-warning: #ffedd5;
--bui-bg-success: #dcfce7;
--bui-bg-info: #dbeafe;
/* Foreground colors */
--bui-fg-primary: var(--bui-black);
@@ -129,10 +130,16 @@
--bui-fg-solid-disabled: #98a8bc;
--bui-fg-tint: #1f5493;
--bui-fg-tint-disabled: var(--bui-gray-5);
--bui-fg-danger: #991919;
--bui-fg-warning: #92310a;
--bui-fg-success: #116932;
--bui-fg-info: #173da6;
/* Foreground Statuses */
--bui-fg-danger-on-bg: #991919;
--bui-fg-warning-on-bg: #92310a;
--bui-fg-success-on-bg: #116932;
--bui-fg-info-on-bg: #173da6;
--bui-fg-danger: #EC3B18;
--bui-fg-warning: #EF7A32;
--bui-fg-success: #1ED760;
--bui-fg-info: #0D74CE;
/* Border colors */
--bui-border: rgba(0, 0, 0, 0.1);
@@ -217,10 +224,16 @@
--bui-fg-solid-disabled: #6191cc;
--bui-fg-tint: #9cc9ff;
--bui-fg-tint-disabled: var(--bui-gray-5);
--bui-fg-danger: #fca5a5;
--bui-fg-warning: #fdba74;
--bui-fg-success: #86efac;
--bui-fg-info: #a3cfff;
/* Foreground statuses */
--bui-fg-danger-on-bg: #fca5a5;
--bui-fg-warning-on-bg: #fdba74;
--bui-fg-success-on-bg: #86efac;
--bui-fg-info-on-bg: #a3cfff;
--bui-fg-danger: #FF5A30;
--bui-fg-warning: #FFA057;
--bui-fg-success: #1ED760;
--bui-fg-info: #70B8FF;
/* Border colors */
--bui-border: rgba(255, 255, 255, 0.12);
+1 -1
View File
@@ -131,7 +131,7 @@ export type TextVariants =
export type TextColors = 'primary' | 'secondary';
/** @public */
export type TextColorStatus = 'danger' | 'warning' | 'success';
export type TextColorStatus = 'danger' | 'warning' | 'success' | 'info';
/** @public */
export type TextWeights = 'regular' | 'bold';