diff --git a/src/components/buttons.js b/src/components/buttons.js
new file mode 100644
index 00000000..47149cc5
--- /dev/null
+++ b/src/components/buttons.js
@@ -0,0 +1,131 @@
+import classnames from 'classnames';
+
+import { SvgIcon } from './SvgIcon';
+
+/**
+ * @typedef ButtonProps
+ * @prop {import("preact").ComponentChildren} [children]
+ * @prop {string} [className]
+ * @prop {string} [icon] - Name of `SvgIcon` to render in the button
+ * @prop {'left'|'right'} [iconPosition] - Icon positioned to left or to
+ * right of button text
+ * @prop {boolean} [disabled]
+ * @prop {boolean} [expanded] - Is the element associated with this button
+ * expanded (set `aria-expanded`)
+ * @prop {boolean} [pressed] - Is this button currently "active?" (set
+ * `aria-pressed`)
+ * @prop {() => any} [onClick]
+ * @prop {'small'|'medium'|'large'} [size='medium'] - Relative button size:
+ * affects padding
+ * @prop {Object} [style] - Optional inline styles
+ * @prop {string} [title] - Button title; used for `aria-label` attribute
+ * @prop {'normal'|'primary'|'light'|'dark'} [variant='normal'] - For styling: element variant
+ */
+
+/**
+ * @typedef IconButtonBaseProps
+ * @prop {string} icon - Icon is required for icon buttons
+ * @prop {string} title - Title is required for icon buttons
+ */
+
+/**
+ * @typedef {ButtonProps & IconButtonBaseProps} IconButtonProps
+ */
+
+/**
+ * @param {ButtonProps} props
+ */
+function ButtonBase({
+ children,
+ className,
+ icon,
+ iconPosition = 'left',
+ disabled,
+ expanded,
+ pressed,
+ onClick = () => {},
+ size = 'medium',
+ style = {},
+ title,
+ variant = 'normal',
+}) {
+ const otherAttributes = {};
+ if (typeof disabled === 'boolean') {
+ otherAttributes.disabled = disabled;
+ }
+ if (typeof title !== 'undefined') {
+ otherAttributes.title = title;
+ otherAttributes['aria-label'] = title;
+ }
+
+ if (typeof expanded === 'boolean') {
+ otherAttributes['aria-expanded'] = expanded;
+ }
+ if (typeof pressed === 'boolean') {
+ otherAttributes['aria-pressed'] = pressed;
+ }
+
+ return (
+
+ );
+}
+
+/**
+ * An icon-only button
+ *
+ * @param {IconButtonProps} props
+ */
+export function IconButton(props) {
+ const { className = 'IconButton', ...restProps } = props;
+ const { icon } = props;
+ return (
+
+
+
+ );
+}
+
+/**
+ * A labeled button, with or without an icon
+ *
+ * @param {ButtonProps} props
+ */
+export function LabeledButton(props) {
+ const { icon, iconPosition = 'left' } = props;
+ const { children, className = 'LabeledButton', ...restProps } = props;
+ return (
+
+ {icon && iconPosition === 'left' && }
+ {children}
+ {icon && iconPosition === 'right' && }
+
+ );
+}
+
+/**
+ * A button styled to appear as an HTML link ()
+ *
+ * @param {ButtonProps} props
+ */
+export function LinkButton(props) {
+ const { children } = props;
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/test/buttons-test.js b/src/components/test/buttons-test.js
new file mode 100644
index 00000000..1b7afe2b
--- /dev/null
+++ b/src/components/test/buttons-test.js
@@ -0,0 +1,257 @@
+import { mount } from 'enzyme';
+
+import { IconButton, LabeledButton, LinkButton } from '../buttons.js';
+import { $imports } from '../buttons.js';
+
+import { checkAccessibility } from '../../../test/util/accessibility';
+import mockImportedComponents from '../../../test/util/mock-imported-components';
+
+// Add common tests for a button component for stuff provided by `ButtonBase`
+function addCommonTests({ componentName, createComponentFn, withIcon = true }) {
+ describe(`${componentName} common support`, () => {
+ if (withIcon) {
+ it('renders the indicated icon', () => {
+ const wrapper = createComponentFn({ icon: 'fakeIcon' });
+ const button = wrapper.find('button');
+ const icon = wrapper.find('SvgIcon');
+ assert.equal(icon.prop('name'), 'fakeIcon');
+ // Icon is positioned "left" even if it is the only element in the