Files
modrinth/standards/frontend/BUTTONS.md
T
Calum H.andProspector bb2193b6f5 refactor: Button components (#6929)
* feat: refactor button components

* fix: qa

* fix: storybook

* a11y: pass

* fix: build

* fix: rename default ->md

* fix: lint

* docs: buttons.md

* refactor part 1

* refactor part 2

* fix: undo refactor for fresh restart

* refactor

* Revert "refactor"

This reverts commit 96d65902d7.

* refactor: part 1

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: remove text-contrast

* fix: qa

* fix: v-tooltip

* fix: lint

* fix: split broken

* fix: passkey qa

* fix: qa

* fix: qa

* fix: prepr

* fix: splitbutton

* improve button group seam

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-04 13:54:04 -07:00

13 KiB

Buttons

Use the button components in packages/ui/src/components/base/buttons/ for actions, navigation, file selection, and button-owned menus. They share the same sizing, interaction states, focus treatment, and visual variants.

Do not recreate button styling with raw <button> or <a> elements. A raw element is appropriate only when an interaction has semantics or behaviour that the shared components cannot represent.

ButtonFrame is an internal styling primitive. Use one of the public components instead of importing it directly.

Choosing a component

Component Use
Button An action, including form submission
ButtonLink Navigation to an internal route or external URL
IconButton An icon-only action
FileButton Opening a file picker, with optional drag-and-drop support
ButtonGroup Visually joining related buttons
SplitButton A primary action with an attached overflow menu
TeleportOverflowMenu An icon button that opens a menu of actions and links
TeleportPopoutMenu A button or icon button that opens an arbitrary panel

Use the component that matches the interaction's semantics. Do not use a click handler on ButtonLink for an action, and do not perform navigation from a Button.

Import public button components from @modrinth/ui. Inside packages/ui, use the corresponding #ui/components/base/buttons/* path:

import { Button, ButtonLink, IconButton } from '@modrinth/ui'

Basic usage

Use Button for actions. Its native type defaults to button, so set native-type="submit" explicitly when it submits a form.

<Button type="colored" @click="save">
	<SaveIcon aria-hidden="true" />
	Save changes
</Button>

<Button native-type="submit" :loading="saveMutation.isPending.value">
	Save
</Button>

Use ButtonLink for navigation. Pass exactly one of to or href:

<ButtonLink to="/settings">Settings</ButtonLink>

<ButtonLink href="https://example.com" target="_blank" type="outlined">
	Documentation
	<ExternalIcon aria-hidden="true" />
</ButtonLink>

to creates a Vue Router link. href creates a standard anchor. External links opened in a new tab receive rel="noopener noreferrer" unless a different rel is provided.

Use IconButton when the visible icon is the entire button content. The label prop is required and supplies the button's accessible name:

<IconButton label="Open settings" type="quiet" @click="openSettings">
	<SettingsIcon aria-hidden="true" />
</IconButton>

Do not replace label with a tooltip or rely on the icon to communicate the action. User-visible labels, including IconButton labels and menu option labels, must use the localization system.

Visual types

All button components use the same four visual types:

Type Use
base Default neutral actions
colored The highest-emphasis action in a section
outlined Secondary actions that need a visible boundary
quiet Low-emphasis actions in toolbars, cards, and other compact contexts

base is the default. Use colored deliberately: a group should normally have only one highest-emphasis action.

The available colors are brand, red, orange, green, blue, purple, and medal_promotion. Color is supported by colored, outlined, and quiet; base uses its standard neutral treatment. An outlined button without a color uses the standard neutral treatment.

<Button type="colored" color="brand">Create project</Button>
<Button type="colored" color="red">Delete project</Button>
<Button type="outlined">Cancel</Button>
<Button type="outlined" color="blue">View analytics</Button>
<Button type="quiet" color="red">Remove</Button>

Quiet buttons support an interaction prop when their hover and keyboard-focus treatment needs to differ from the default surface fill:

Interaction Treatment
surface Uses the standard neutral hover/focus surface. This is the default.
filled Fills with the button's color and uses contrast text.
none Keeps the background transparent while retaining the focus ring.
<Button type="quiet" color="brand" interaction="filled">Current page</Button>
<IconButton type="quiet" interaction="none" label="Open account menu">
	<AvatarIcon aria-hidden="true" />
</IconButton>

Use interaction to describe behavior rather than passing arbitrary hover colors. Resting selected-state backgrounds, such as a current pagination page, remain the responsibility of the owning component.

Use red only for destructive or dangerous actions. Do not use color as the only way to communicate an action's meaning.

Sizes

  • sm: Notifications and popups, usually for icon-only actions.
  • md: Modal and card actions that do not appear at the top of a page.
  • lg: Actions at the top of a page or subpage, such as beside inputs or filters.
  • xl: Header actions.

md is the default. Buttons that appear together must use the same size.

Prefer the size prop over overriding height, padding, border radius, font weight, gap, or icon dimensions with utility classes. Width and layout utilities such as w-full are fine when required by the surrounding layout.

<Button size="sm">Compact action</Button>
<Button>Default action</Button>
<Button size="lg">Prominent action</Button>
<Button size="xl">Large action</Button>

States

Use disabled when an action is unavailable. Use loading while a Button or IconButton action is in progress:

<Button
	type="colored"
	:loading="createMutation.isPending.value"
	@click="createMutation.mutate()"
>
	Create
</Button>

loading disables the underlying button and sets aria-busy. Keep the label stable so the action remains understandable while it is running.

Disabled links are made non-navigable and removed from the tab order by ButtonLink. Do not reimplement disabled-link click handling in consumers.

Icons and content

Place icons directly inside the component so the shared styles can size and space them:

<Button>
	<DownloadIcon aria-hidden="true" />
	Download
</Button>

<Button>
	Continue
	<RightArrowIcon aria-hidden="true" />
</Button>

Icons next to a visible label are decorative and should use aria-hidden="true". Use IconButton instead of an empty text button for icon-only actions.

Button labels should describe the action directly. Avoid vague labels such as "Yes", "OK", or "Submit" when a specific label such as "Delete version" or "Save settings" would be clearer.

File buttons

FileButton owns the file input and emits validated files:

<FileButton
	prompt="Select images"
	accept="image/*"
	:max-size="MAX_IMAGE_SIZE"
	multiple
	@change="handleFiles"
>
	<UploadIcon aria-hidden="true" />
</FileButton>

The change event receives a File[]. allow-drop defaults to true; set it to false when the surrounding interface should not accept dropped files.

Use FileButton for a compact file-selection action. Use a dedicated drop area when drag-and-drop is the primary interaction or the interface needs previews, progress, or more detailed instructions.

Button groups and split buttons

Use ButtonGroup only for closely related controls. Pass a localized label when the relationship is not already clear from surrounding accessible content:

<ButtonGroup label="Pagination">
	<Button type="outlined">Previous</Button>
	<Button type="outlined">Next</Button>
</ButtonGroup>

ButtonGroup joins the buttons visually, so its direct children must be components from this button system.

Use SplitButton when one action is primary and closely related alternatives belong in an attached menu:

<script setup lang="ts">
import { PlayIcon, SettingsIcon, TrashIcon } from '@modrinth/assets'
import type { OverflowMenuOption } from '@modrinth/ui/src/components/base/buttons/types'

const options: OverflowMenuOption[] = [
	{
		id: 'settings',
		label: 'Server settings',
		icon: SettingsIcon,
		action: openSettings,
	},
	{ type: 'divider' },
	{
		id: 'delete',
		label: 'Delete server',
		icon: TrashIcon,
		tone: 'red',
		action: deleteServer,
	},
]
</script>

<template>
	<SplitButton
		menu-label="More server actions"
		group-label="Server actions"
		type="colored"
		:options="options"
		@click="startServer"
	>
		<PlayIcon aria-hidden="true" />
		Start server
	</SplitButton>
</template>

Use primary-disabled and menu-disabled when only one side is unavailable. Use disabled when the entire split button is unavailable.

Overflow menus

TeleportOverflowMenu is for a menu of discrete actions and navigation targets. Define its options as OverflowMenuOption[]:

const options: OverflowMenuOption[] = [
	{
		id: 'download',
		label: 'Download',
		icon: DownloadIcon,
		action: download,
	},
	{
		id: 'project-page',
		label: 'Open project page',
		icon: ExternalIcon,
		type: 'link',
		to: projectRoute,
	},
	{ type: 'divider' },
	{
		id: 'delete',
		label: 'Delete',
		icon: TrashIcon,
		tone: 'red',
		action: remove,
	},
]

Each non-divider option needs a stable, unique id and a localized label.

The trigger is an IconButton by default. Set icon-only="false" when the trigger has visible text or composite content, such as an avatar and chevron; it will use a normal Button so intrinsic width, padding, and gaps are preserved.

  • Use an action option for behaviour and a link option for navigation.
  • Use shown: false to remove an option conditionally.
  • Use disabled with a tooltip when the user needs to understand why an option is unavailable.
  • Use remainOpen only when selecting the option should not dismiss the menu.
  • Use tone for semantically colored options. The available tones are brand, red, orange, green, blue, purple, and medal_promotion.
  • Use hoverFilled: true when a toned option should fill with its tone on hover and focus. Use hoverFilledOnly: true to keep the default text color until that state.
  • Use tone: 'red' for destructive options.
  • Use dividers sparingly to separate meaningful groups.

The component implements menu keyboard navigation, focus management, typeahead, and Escape handling. Consumers should not add competing keyboard or focus behaviour.

Set hoverable when a navigation menu should also open on pointer hover. Click, touch, and keyboard activation remain available.

Popout menus

Use TeleportPopoutMenu for arbitrary interactive content that belongs to a button, such as a compact settings panel. Use TeleportOverflowMenu instead when the content is only a list of actions or links.

<TeleportPopoutMenu label="Configure versions" placement="bottom-start">
	<template #trigger>
		<SettingsIcon aria-hidden="true" />
		Configure
	</template>

	<template #panel="{ close }">
		<div class="flex w-72 flex-col gap-4">
			<StyledInput v-model="versionName" label="Version name" />
			<Button type="colored" @click="close()">Apply</Button>
		</div>
	</template>
</TeleportPopoutMenu>

Set icon-only when the trigger has no visible text; label then becomes the trigger's accessible name. The panel role defaults to dialog; use panel-role="region" only when the content is non-modal supplementary content.

The panel slot receives close, and the component manages initial focus, Escape, and focus restoration.

Legacy components

Do not introduce new uses of the legacy packages/ui/src/components/base/Button.vue or ButtonStyled.vue APIs. When migrating:

Legacy pattern Replacement
link / external ButtonLink with to, href, and target
action @click on Button
icon-only IconButton with label
large size="lg" or size="xl"
outline type="outlined"
transparent type="quiet"
ButtonStyled wrapping an element The matching direct button component

Preserve the original element's semantics while migrating. A visual match is not enough if a link becomes a button or an icon-only action loses its accessible name.