diff --git a/packages/ui/package.json b/packages/ui/package.json
index 4ce1ae4236..b241b6cde1 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -37,6 +37,7 @@
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
+ "@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
@@ -84,6 +85,7 @@
"@storybook/react": "^8.1.1",
"@storybook/react-vite": "8.1.1",
"@storybook/theming": "^8.1.11",
+ "@testing-library/user-event": "^14.5.2",
"@types/humanize-duration": "^3.27.4",
"@types/murmurhash3js": "^3.0.7",
"@types/react": "^18.3.2",
diff --git a/packages/ui/src/AddressViewComponent/AddressIcon.tsx b/packages/ui/src/AddressViewComponent/AddressIcon.tsx
new file mode 100644
index 0000000000..05310d2da8
--- /dev/null
+++ b/packages/ui/src/AddressViewComponent/AddressIcon.tsx
@@ -0,0 +1,15 @@
+import { Address } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js';
+import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra';
+import { Identicon } from '../Identicon';
+
+export interface AddressIconProps {
+ address: Address;
+ size: number;
+}
+
+/**
+ * A simple component to display a consistently styled icon for a given address.
+ */
+export const AddressIcon = ({ address, size }: AddressIconProps) => (
+
+);
diff --git a/packages/ui/src/AddressViewComponent/index.stories.tsx b/packages/ui/src/AddressViewComponent/index.stories.tsx
new file mode 100644
index 0000000000..80df42e238
--- /dev/null
+++ b/packages/ui/src/AddressViewComponent/index.stories.tsx
@@ -0,0 +1,67 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { AddressViewComponent } from '.';
+import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js';
+import { addressFromBech32m } from '@penumbra-zone/bech32m/penumbra';
+import styled from 'styled-components';
+
+const EXAMPLE_VIEW_DECODED = new AddressView({
+ addressView: {
+ case: 'decoded',
+
+ value: {
+ address: { inner: new Uint8Array(80) },
+ index: {
+ account: 0,
+ randomizer: new Uint8Array([0, 0, 0]),
+ },
+ },
+ },
+});
+
+const EXAMPLE_VIEW_OPAQUE = new AddressView({
+ addressView: {
+ case: 'opaque',
+ value: {
+ address: addressFromBech32m(
+ 'penumbra1e8k5cyds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uu0rgkvtjpxy3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd4',
+ ),
+ },
+ },
+});
+
+const MaxWidthWrapper = styled.div`
+ width: 100%;
+ overflow: hidden;
+`;
+
+const meta: Meta = {
+ component: AddressViewComponent,
+ tags: ['autodocs', '!dev'],
+ argTypes: {
+ addressView: {
+ options: ['Sample decoded address view', 'Sample opaque address view'],
+ mapping: {
+ 'Sample decoded address view': EXAMPLE_VIEW_DECODED,
+ 'Sample opaque address view': EXAMPLE_VIEW_OPAQUE,
+ },
+ },
+ },
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Basic: Story = {
+ args: {
+ addressView: EXAMPLE_VIEW_DECODED,
+ copyable: true,
+ },
+};
diff --git a/packages/ui/src/AddressViewComponent/index.test.tsx b/packages/ui/src/AddressViewComponent/index.test.tsx
new file mode 100644
index 0000000000..0c2fe536da
--- /dev/null
+++ b/packages/ui/src/AddressViewComponent/index.test.tsx
@@ -0,0 +1,82 @@
+import {
+ Address,
+ AddressIndex,
+ AddressView,
+ AddressView_Decoded,
+} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js';
+import { AddressViewComponent } from '.';
+import { describe, expect, it } from 'vitest';
+import { render } from '@testing-library/react';
+import { PenumbraUIProvider } from '../PenumbraUIProvider';
+
+const addressViewWithOneTimeAddress = new AddressView({
+ addressView: {
+ case: 'decoded',
+
+ value: new AddressView_Decoded({
+ address: new Address({ inner: new Uint8Array(80) }),
+ index: new AddressIndex({
+ account: 0,
+ // A one-time address is defined by a randomizer with at least one
+ // non-zero byte.
+ randomizer: new Uint8Array([1, 2, 3]),
+ }),
+ }),
+ },
+});
+
+const addressViewWithNormalAddress = new AddressView({
+ addressView: {
+ case: 'decoded',
+
+ value: new AddressView_Decoded({
+ address: new Address({ inner: new Uint8Array(80) }),
+ index: new AddressIndex({
+ account: 0,
+ randomizer: new Uint8Array([0, 0, 0]),
+ }),
+ }),
+ },
+});
+
+describe('', () => {
+ describe('when `copyable` is `true`', () => {
+ it('does not show the copy icon when the address is a one-time address', () => {
+ const { queryByLabelText } = render(
+ ,
+ { wrapper: PenumbraUIProvider },
+ );
+
+ expect(queryByLabelText('Copy')).toBeNull();
+ });
+
+ it('shows the copy icon when the address is not a one-time address', () => {
+ const { queryByLabelText } = render(
+ ,
+ { wrapper: PenumbraUIProvider },
+ );
+
+ expect(queryByLabelText('Copy')).not.toBeNull();
+ });
+ });
+
+ describe('when `copyable` is `false`', () => {
+ it('does not show the copy icon when the address is a one-time address', () => {
+ const { queryByLabelText } = render(
+ ,
+ { wrapper: PenumbraUIProvider },
+ );
+
+ expect(queryByLabelText('Copy')).toBeNull();
+ });
+
+ it('does not show the copy icon when the address is not a one-time address', () => {
+ const { queryByLabelText } = render(
+ ,
+ { wrapper: PenumbraUIProvider },
+ );
+
+ expect(queryByLabelText('Copy')).toBeNull();
+ });
+ });
+});
diff --git a/packages/ui/src/AddressViewComponent/index.tsx b/packages/ui/src/AddressViewComponent/index.tsx
new file mode 100644
index 0000000000..76318d176d
--- /dev/null
+++ b/packages/ui/src/AddressViewComponent/index.tsx
@@ -0,0 +1,66 @@
+import { AddressIcon } from './AddressIcon';
+import { AddressView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb.js';
+import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra';
+import styled from 'styled-components';
+import { Text } from '../Text';
+import { CopyToClipboardButton } from '../CopyToClipboardButton';
+import { Shrink0 } from '../utils/Shrink0';
+
+const Root = styled.div`
+ display: flex;
+ gap: ${props => props.theme.spacing(2)};
+ align-items: center;
+`;
+
+export interface AddressViewProps {
+ addressView: AddressView | undefined;
+ copyable?: boolean;
+}
+
+// Renders an address or an address view.
+// If the view is given and is "visible", the account information will be displayed instead.
+export const AddressViewComponent = ({ addressView, copyable = true }: AddressViewProps) => {
+ if (!addressView?.addressView.value?.address) {
+ return null;
+ }
+
+ const accountIndex =
+ addressView.addressView.case === 'decoded'
+ ? addressView.addressView.value.index?.account
+ : undefined;
+ const isOneTimeAddress =
+ addressView.addressView.case === 'decoded'
+ ? !addressView.addressView.value.index?.randomizer.every(v => v === 0) // Randomized (and thus, a one-time address) if the randomizer is not all zeros.
+ : undefined;
+
+ const addressIndexLabel = isOneTimeAddress ? 'IBC Deposit Address for Account #' : 'Account #';
+
+ copyable = isOneTimeAddress ? false : copyable;
+
+ const encodedAddress = bech32mAddress(addressView.addressView.value.address);
+
+ return (
+
+
+
+
+
+ {accountIndex === undefined ? (
+
+ {encodedAddress}
+
+ ) : (
+
+ {addressIndexLabel}
+ {accountIndex}
+
+ )}
+
+ {copyable && (
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/ui/src/Button/helpers.test.ts b/packages/ui/src/Button/helpers.test.ts
index d8aab34473..9edd51f9c7 100644
--- a/packages/ui/src/Button/helpers.test.ts
+++ b/packages/ui/src/Button/helpers.test.ts
@@ -19,6 +19,12 @@ describe('getBackgroundColor()', () => {
it('returns the corresponding color for other action types', () => {
expect(getBackgroundColor('destructive', 'primary', theme)).toBe('#f00');
});
+
+ describe('when `iconOnly` is `true`', () => {
+ it('returns the primary color for the `accent` action type', () => {
+ expect(getBackgroundColor('accent', 'primary', theme, true)).toBe('#aaa');
+ });
+ });
});
describe('when `priority` is `secondary`', () => {
@@ -26,4 +32,10 @@ describe('getBackgroundColor()', () => {
expect(getBackgroundColor('accent', 'secondary', theme)).toBe('transparent');
});
});
+
+ describe('when `iconOnly` is `adornment`', () => {
+ it('returns `transparent`', () => {
+ expect(getBackgroundColor('accent', 'primary', theme, 'adornment')).toBe('transparent');
+ });
+ });
});
diff --git a/packages/ui/src/Button/helpers.ts b/packages/ui/src/Button/helpers.ts
index 9a8129aa25..ad583ee5e4 100644
--- a/packages/ui/src/Button/helpers.ts
+++ b/packages/ui/src/Button/helpers.ts
@@ -5,8 +5,9 @@ export const getBackgroundColor = (
actionType: ActionType,
priority: Priority,
theme: DefaultTheme,
+ iconOnly?: boolean | 'adornment',
): string => {
- if (priority === 'secondary') {
+ if (priority === 'secondary' || iconOnly === 'adornment') {
return 'transparent';
}
diff --git a/packages/ui/src/Button/index.stories.tsx b/packages/ui/src/Button/index.stories.tsx
index 2ffadfa5be..6516a59cd9 100644
--- a/packages/ui/src/Button/index.stories.tsx
+++ b/packages/ui/src/Button/index.stories.tsx
@@ -12,6 +12,10 @@ const meta: Meta = {
options: ['None', 'Check', 'ArrowLeftRight'],
mapping: { None: undefined, Check, ArrowLeftRight },
},
+ iconOnly: {
+ options: ['true', 'false', 'adornment'],
+ mapping: { true: true, false: false, adornment: 'adornment' },
+ },
onClick: { control: false },
},
};
@@ -26,5 +30,6 @@ export const Basic: Story = {
disabled: false,
icon: Check,
iconOnly: false,
+ type: 'button',
},
};
diff --git a/packages/ui/src/Button/index.tsx b/packages/ui/src/Button/index.tsx
index 9dbde31726..543c08bb03 100644
--- a/packages/ui/src/Button/index.tsx
+++ b/packages/ui/src/Button/index.tsx
@@ -1,7 +1,7 @@
import { MouseEventHandler, useContext } from 'react';
import styled, { css, DefaultTheme } from 'styled-components';
import { asTransientProps } from '../utils/asTransientProps';
-import { Priority, ActionType, focusOutline, overlays } from '../utils/button';
+import { Priority, ActionType, focusOutline, overlays, buttonBase } from '../utils/button';
import { getBackgroundColor } from './helpers';
import { button } from '../utils/typography';
import { LucideIcon } from 'lucide-react';
@@ -9,6 +9,11 @@ import { ButtonPriorityContext } from '../utils/ButtonPriorityContext';
import { Density } from '../types/Density';
import { useDensity } from '../hooks/useDensity';
+const iconOnlyAdornment = css`
+ border-radius: ${props => props.theme.borderRadius.full};
+ padding: ${props => props.theme.spacing(1)};
+`;
+
const sparse = css`
border-radius: ${props => props.theme.borderRadius.sm};
padding-left: ${props => props.theme.spacing(4)};
@@ -43,7 +48,7 @@ const borderColorByActionType: Record<
};
interface StyledButtonProps {
- $iconOnly?: boolean;
+ $iconOnly?: boolean | 'adornment';
$actionType: ActionType;
$priority: Priority;
$density: Density;
@@ -52,10 +57,11 @@ interface StyledButtonProps {
}
const StyledButton = styled.button`
+ ${buttonBase}
${button}
- background-color: ${props => getBackgroundColor(props.$actionType, props.$priority, props.theme)};
- border: none;
+ background-color: ${props =>
+ getBackgroundColor(props.$actionType, props.$priority, props.theme, props.$iconOnly)};
outline: ${props =>
props.$priority === 'secondary'
? `1px solid ${props.theme.color[borderColorByActionType[props.$actionType]].main}`
@@ -66,11 +72,15 @@ const StyledButton = styled.button`
align-items: center;
justify-content: center;
color: ${props => props.theme.color.neutral.contrast};
- cursor: pointer;
overflow: hidden;
position: relative;
- ${props => (props.$density === 'sparse' ? sparse : compact)}
+ ${props =>
+ props.$iconOnly === 'adornment'
+ ? iconOnlyAdornment
+ : props.$density === 'sparse'
+ ? sparse
+ : compact}
${focusOutline}
${overlays}
@@ -83,8 +93,8 @@ const StyledButton = styled.button`
interface BaseButtonProps {
type?: HTMLButtonElement['type'];
/**
- * The button label. If `iconOnly` is `true`, this will be used as the
- * `aria-label` attribute.
+ * The button label. If `iconOnly` is `true` or `adornment`, this will be used
+ * as the `aria-label` attribute.
*/
children: string;
/**
@@ -102,10 +112,20 @@ interface BaseButtonProps {
interface IconOnlyProps {
/**
- * When `true`, will render just an icon button. The label text passed via
- * `children` will be used as the `aria-label`.
+ * When set to `true`, will render just an icon button. When set to
+ * `adornment`, will render an icon button without the fill or outline of a
+ * normal button. This latter case is useful when the button is an adornment
+ * to another component (e.g., when it's a copy icon attached to an
+ * `AddressViewComponent`).
+ *
+ * In both of these cases, the label text passed via `children` will be used
+ * as the `aria-label`.
+ *
+ * Note that, when `iconOnly` is `adornment`, density has no impact on the
+ * button: it will render at the same size in either a `compact` or `sparse`
+ * context.
*/
- iconOnly: true;
+ iconOnly: true | 'adornment';
/**
* The icon import from `lucide-react` to render. If `iconOnly` is `true`, no
* label will be rendered -- just the icon. Otherwise, the icon will be
@@ -121,10 +141,6 @@ interface IconOnlyProps {
}
interface RegularProps {
- /**
- * When `true`, will render just an icon button. The label text passed via
- * `children` will be used as the `aria-label`.
- */
iconOnly?: false;
/**
* The icon import from `lucide-react` to render. If `iconOnly` is `true`, no
@@ -165,10 +181,14 @@ export const Button = ({
title={iconOnly ? children : undefined}
$getFocusOutlineColor={theme => theme.color.action[outlineColorByActionType[actionType]]}
$getBorderRadius={theme =>
- density === 'sparse' ? theme.borderRadius.sm : theme.borderRadius.full
+ density === 'sparse' && iconOnly !== 'adornment'
+ ? theme.borderRadius.sm
+ : theme.borderRadius.full
}
>
- {IconComponent && }
+ {IconComponent && (
+
+ )}
{!iconOnly && children}
diff --git a/packages/ui/src/CopyToClipboardButton/index.stories.tsx b/packages/ui/src/CopyToClipboardButton/index.stories.tsx
new file mode 100644
index 0000000000..023b3e78ed
--- /dev/null
+++ b/packages/ui/src/CopyToClipboardButton/index.stories.tsx
@@ -0,0 +1,17 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { CopyToClipboardButton } from '.';
+
+const meta: Meta = {
+ component: CopyToClipboardButton,
+ tags: ['autodocs', '!dev'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Basic: Story = {
+ args: {
+ text: 'This is sample text copied by the PenumbraUI component.',
+ },
+};
diff --git a/packages/ui/src/CopyToClipboardButton/index.test.tsx b/packages/ui/src/CopyToClipboardButton/index.test.tsx
new file mode 100644
index 0000000000..9f123cdebf
--- /dev/null
+++ b/packages/ui/src/CopyToClipboardButton/index.test.tsx
@@ -0,0 +1,44 @@
+import { beforeAll, describe, expect, it } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import { CopyToClipboardButton } from '.';
+import { fireEvent, render, waitFor } from '@testing-library/react';
+import { PenumbraUIProvider } from '../PenumbraUIProvider';
+
+describe('', () => {
+ beforeAll(() => {
+ userEvent.setup();
+ });
+
+ it('copies the value of the `text` prop to the clipboard', async () => {
+ const { getByLabelText } = render(, {
+ wrapper: PenumbraUIProvider,
+ });
+
+ fireEvent.click(getByLabelText('Copy'));
+
+ const clipboardText = await navigator.clipboard.readText();
+
+ expect(clipboardText).toBe('copy that');
+ });
+
+ it('has an initial label of "Copy"', () => {
+ const { queryByLabelText } = render(, {
+ wrapper: PenumbraUIProvider,
+ });
+
+ expect(queryByLabelText('Copy')).toBeTruthy();
+ });
+
+ it('changes the label to "Copied" after the user clicks it', async () => {
+ const { getByLabelText, queryByLabelText } = render(
+ ,
+ {
+ wrapper: PenumbraUIProvider,
+ },
+ );
+
+ fireEvent.click(getByLabelText('Copy'));
+
+ await waitFor(() => expect(queryByLabelText('Copied')).toBeTruthy());
+ });
+});
diff --git a/packages/ui/src/CopyToClipboardButton/index.tsx b/packages/ui/src/CopyToClipboardButton/index.tsx
new file mode 100644
index 0000000000..8517f684c0
--- /dev/null
+++ b/packages/ui/src/CopyToClipboardButton/index.tsx
@@ -0,0 +1,42 @@
+import { ButtonHTMLAttributes, useState } from 'react';
+import { Copy, Check, LucideIcon } from 'lucide-react';
+import { Button } from '../Button';
+
+const useClipboardButton = (text: string) => {
+ const [icon, setIcon] = useState(Copy);
+ const [label, setLabel] = useState('Copy');
+
+ const onClick = () => {
+ setIcon(Check);
+ setLabel('Copied');
+ setTimeout(() => {
+ setIcon(Copy);
+ setLabel('Copy');
+ }, 2000);
+ void navigator.clipboard.writeText(text);
+ };
+
+ return { onClick, icon, label };
+};
+
+export interface CopyToClipboardButtonProps extends ButtonHTMLAttributes {
+ /**
+ * The text that should be copied to the clipboard when the user presses this
+ * button.
+ */
+ text: string;
+}
+
+/**
+ * A simple icon button for copying some text to the clipboard. Use it alongside
+ * text that the user may want to copy.
+ */
+export const CopyToClipboardButton = ({ text }: CopyToClipboardButtonProps) => {
+ const { onClick, icon, label } = useClipboardButton(text);
+
+ return (
+
+ );
+};
diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts b/packages/ui/src/Identicon/generate.ts
similarity index 100%
rename from packages/ui/src/ValueViewComponent/AssetIcon/Identicon/generate.ts
rename to packages/ui/src/Identicon/generate.ts
diff --git a/packages/ui/src/Identicon/index.stories.tsx b/packages/ui/src/Identicon/index.stories.tsx
new file mode 100644
index 0000000000..48bb6ccb25
--- /dev/null
+++ b/packages/ui/src/Identicon/index.stories.tsx
@@ -0,0 +1,19 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { Identicon } from '.';
+
+const meta: Meta = {
+ component: Identicon,
+ tags: ['autodocs', '!dev'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Basic: Story = {
+ args: {
+ uniqueIdentifier: 'abc123',
+ type: 'solid',
+ size: 24,
+ },
+};
diff --git a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx b/packages/ui/src/Identicon/index.tsx
similarity index 50%
rename from packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx
rename to packages/ui/src/Identicon/index.tsx
index 7b30f26d11..3959a760fd 100644
--- a/packages/ui/src/ValueViewComponent/AssetIcon/Identicon/index.tsx
+++ b/packages/ui/src/Identicon/index.tsx
@@ -2,10 +2,19 @@ import { useMemo } from 'react';
import { generateGradient, generateSolidColor } from './generate';
import styled from 'styled-components';
+/**
+ * The view box size is separate from the passed-in `size` prop.
+ *
+ * The view box controls how the elements inside the SVG are sized in relation
+ * to the SVG as a whole. The passed-in `size` prop controls how big the SVG as
+ * a whole is.
+ */
+const VIEW_BOX_SIZE = 24;
+
const Svg = styled.svg.attrs<{ $size: number }>(props => ({
width: props.$size,
height: props.$size,
- viewBox: `0 0 ${props.$size} ${props.$size}`,
+ viewBox: `0 0 ${VIEW_BOX_SIZE} ${VIEW_BOX_SIZE}`,
version: '1.1',
xmlns: 'http://www.w3.org/2000/svg',
}))`
@@ -13,13 +22,35 @@ const Svg = styled.svg.attrs<{ $size: number }>(props => ({
border-radius: ${props => props.theme.borderRadius.full};
`;
+const SvgText = styled.text`
+ text-transform: uppercase;
+ font-family: ${props => props.theme.font.default};
+`;
+
export interface IdenticonProps {
+ /**
+ * The ID or other string representation of the object you want an identicon
+ * for. `` will deterministically generate a solid color or
+ * gradient (depending on the value of `type`) based on the value of
+ * `uniqueIdentifier`.
+ */
uniqueIdentifier: string;
+ /** The identicon size, in pixels. */
size?: number;
- className?: string;
+ /**
+ * When `solid`, will render a solid color along with the (upper-cased) first
+ * character of `uniqueIdentifier`. When `gradient`, will render just a
+ * gradient.
+ */
type: 'gradient' | 'solid';
}
+/**
+ * Renders an SVG icon whose color or gradient is deterministically generated
+ * based on the value of the `uniqueIdentifier` prop.
+ *
+ * Use this for assets, addresses, etc. that don't otherwise have an icon.
+ */
export const Identicon = (props: IdenticonProps) => {
if (props.type === 'gradient') {
return ;
@@ -40,7 +71,13 @@ const IdenticonGradient = ({ uniqueIdentifier, size = 120 }: IdenticonProps) =>
-
+
);
@@ -51,18 +88,10 @@ const IdenticonSolid = ({ uniqueIdentifier, size = 120 }: IdenticonProps) => {
return (
);
};
diff --git a/packages/ui/src/PenumbraUIProvider/theme.ts b/packages/ui/src/PenumbraUIProvider/theme.ts
index 6fc39adce0..d133e46340 100644
--- a/packages/ui/src/PenumbraUIProvider/theme.ts
+++ b/packages/ui/src/PenumbraUIProvider/theme.ts
@@ -181,7 +181,7 @@ export const theme = {
special: PALETTE.orange['400'],
},
action: {
- hoverOverlay: PALETTE.neutral['50'] + FIFTEEN_PERCENT_OPACITY_IN_HEX,
+ hoverOverlay: PALETTE.teal['400'] + FIFTEEN_PERCENT_OPACITY_IN_HEX,
activeOverlay: PALETTE.neutral['950'] + FIFTEEN_PERCENT_OPACITY_IN_HEX,
disabledOverlay: PALETTE.neutral['950'] + EIGHTY_PERCENT_OPACITY_IN_HEX,
primaryFocusOutline: PALETTE.orange['400'],
diff --git a/packages/ui/src/SegmentedControl/index.stories.tsx b/packages/ui/src/SegmentedControl/index.stories.tsx
new file mode 100644
index 0000000000..829d402741
--- /dev/null
+++ b/packages/ui/src/SegmentedControl/index.stories.tsx
@@ -0,0 +1,41 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useArgs } from '@storybook/preview-api';
+
+import { SegmentedControl } from '.';
+
+const OPTIONS = [
+ { label: 'One', value: 'one' },
+ { label: 'Two', value: 'two' },
+ { label: 'Three', value: 'three' },
+ { label: 'Four (disabled)', value: 'four', disabled: true },
+];
+
+const meta: Meta = {
+ component: SegmentedControl,
+ tags: ['autodocs', '!dev', 'density'],
+ argTypes: {
+ value: {
+ control: 'select',
+ options: OPTIONS.filter(({ disabled }) => !disabled).map(({ value }) => value),
+ },
+ options: { control: false },
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Basic: Story = {
+ args: {
+ options: OPTIONS,
+ value: 'one',
+ },
+
+ render: function Render({ value, options }) {
+ const [, updateArgs] = useArgs();
+
+ const onChange = (value: { toString: () => string }) => updateArgs({ value });
+
+ return ;
+ },
+};
diff --git a/packages/ui/src/SegmentedControl/index.test.tsx b/packages/ui/src/SegmentedControl/index.test.tsx
new file mode 100644
index 0000000000..78c00b91b8
--- /dev/null
+++ b/packages/ui/src/SegmentedControl/index.test.tsx
@@ -0,0 +1,38 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { fireEvent, render } from '@testing-library/react';
+import { SegmentedControl } from '.';
+import { PenumbraUIProvider } from '../PenumbraUIProvider';
+
+describe('', () => {
+ const onChange = vi.fn();
+ const options = [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: 'Two' },
+ { value: 'three', label: 'Three' },
+ ];
+
+ beforeEach(() => {
+ onChange.mockReset();
+ });
+
+ it('renders all passed-in options', () => {
+ const { container } = render(
+ ,
+ { wrapper: PenumbraUIProvider },
+ );
+
+ expect(container).toHaveTextContent('One');
+ expect(container).toHaveTextContent('Two');
+ expect(container).toHaveTextContent('Three');
+ });
+
+ it('calls the `onClick` handler with the value of the clicked option', () => {
+ const { getByText } = render(
+ ,
+ { wrapper: PenumbraUIProvider },
+ );
+ fireEvent.click(getByText('Two', { selector: ':not([aria-hidden])' }));
+
+ expect(onChange).toHaveBeenCalledWith('two');
+ });
+});
diff --git a/packages/ui/src/SegmentedControl/index.tsx b/packages/ui/src/SegmentedControl/index.tsx
new file mode 100644
index 0000000000..7c86997567
--- /dev/null
+++ b/packages/ui/src/SegmentedControl/index.tsx
@@ -0,0 +1,92 @@
+import styled, { DefaultTheme } from 'styled-components';
+import { button } from '../utils/typography';
+import { focusOutline, overlays, buttonBase } from '../utils/button';
+import { Density } from '../types/Density';
+import { useDensity } from '../hooks/useDensity';
+import * as RadixRadioGroup from '@radix-ui/react-radio-group';
+
+const Root = styled.div`
+ display: flex;
+ gap: ${props => props.theme.spacing(2)};
+`;
+
+const Segment = styled.button<{
+ $getFocusOutlineColor: (theme: DefaultTheme) => string;
+ $getBorderRadius: (theme: DefaultTheme) => string;
+ $selected: boolean;
+ $density: Density;
+}>`
+ ${buttonBase}
+ ${button}
+ ${overlays}
+ ${focusOutline}
+
+ color:${props => props.theme.color.base.white};
+ border: 1px solid
+ ${props =>
+ props.$selected ? props.theme.color.neutral.light : props.theme.color.other.tonalStroke};
+ border-radius: ${props => props.theme.borderRadius.full};
+
+ padding-top: ${props => props.theme.spacing(props.$density === 'sparse' ? 2 : 1)};
+ padding-bottom: ${props => props.theme.spacing(props.$density === 'sparse' ? 2 : 1)};
+ padding-left: ${props => props.theme.spacing(props.$density === 'sparse' ? 4 : 2)};
+ padding-right: ${props => props.theme.spacing(props.$density === 'sparse' ? 4 : 2)};
+`;
+
+export interface Option {
+ value: string;
+ label: string;
+ disabled?: boolean;
+}
+
+export interface SegmentedControlProps {
+ value: string;
+ onChange: (value: string) => void;
+ options: Option[];
+}
+
+/**
+ * Renders a segmented control where only one option can be selected at a time.
+ * Functionally equivalent to a `