MobTranslate styleguide

Button

Trigger a clear action, choose its emphasis and expose the real task state.

Choose the appropriate action

NeedChoiceDecision
Main task actionprimaryUsually one per task region.
Paired alternativesecondaryTransparent bordered action; it does not use the secondary filled color role.
Low emphasis actionghostKeep its label and hit area discoverable.
Consequential changedestructiveExplain the consequence; provide confirmation or undo according to the task.
Status-oriented legacy actionsuccess / warning / errorSupported compatibility variants; use only when action meaning justifies the color.
Legacy outlineoutlineStyled alias of secondary.
Text-style actionlinkStill a button unless composed as a real link. Compatibility styling removes inline padding; the selected text-size minimum still applies. Inspect the actual target.

Anatomy and sizing

A button combines its hit area, visible label, optional decorative icon, state treatment and focus indicator. Padding is not the accessible name, and glyph size is not the target size.

SizeRendered CSSUse
sm36px minimum height; 12px inline padding; --text-smCompact pointer contexts. For touch, prefer md or expand the target.
md44px minimum height; 16px inline padding; --text-baseDefault action target.
lg48px minimum height; 24px inline padding; --text-lgProminent or spacious action regions.
icon44×44px; no inline paddingRequires an accessible name; prefer IconButton for a typed naming requirement.
Shape and focus--radius-md; --color-ring with background separationInspect the actual surface and forced-colors outline.
Motion--duration-normal / --ease-defaultShared reduced-motion rule minimizes animation.

Long labels and enlarged text

Text buttons wrap within their available inline space and grow beyond the selected minimum height. The label remains complete in the DOM and inside the visible hit area; it is not clipped, truncated or reduced to fit. The three text-size height tokens retain their legacy names but now bind to min-height; icon-size buttons retain their fixed square geometry. Single-line text buttons retain the existing nominal dimensions.

Use logical layout and allow flex/grid containers to shrink or wrap their children. Preserve meaningful labels at 200% text enlargement and narrow viewports. An intentional compact display or icon-only control has a separate documented naming/scrolling contract; do not force every action label onto one line.

Material reference and local action policy

The captured reference distinguishes five emphasis styles, default versus selected actions, five expressive sizes, round/square shapes and pressed/selected morphing. This library has its own three text sizes, fixed icon size and nine exported variant names. Names are not a conversion API: secondary is a transparent bordered action, and link is an underlined compatibility style. Use ghost for a quiet command and a real anchor for navigation.

The reference design guideline favors concise single-line labels; its accessibility chapter separately discusses enlarged labels and rapid interaction. This web implementation preserves the full label and permits wrapping at narrow widths and 200% text size. Start with concise wording, then let the target grow. Selected actions expose aria-pressed and visible state; shape-changing selection and neighbor-width motion are not implicit Button behavior.

Reference concernLocal implementation decision
Emphasis and backgroundsChoose primary, secondary or ghost according to task hierarchy; inspect image/gradient surfaces with the real foreground and focus boundary. Existing elevation and color tools explain shadow/role choices.
Decorative iconUse one leading glyph through leftIcon, hidden from the accessibility tree. Logical layout changes its side; keep the task label and native target intact.
Color contrastCheck normal-size label text against 4.5:1 and meaningful boundaries/glyphs against 3:1. A passing boundary ratio alone does not approve text.
Repeated activationPending blocks duplicate activation in the lab. State colors and size decisions update immediately; shadow transitions do not interpolate foreground/background pairs or queue overlapping geometry animations.
Density and sizeThe source-bound sizing inspector reports nominal minimums and actual rectangles. No XS/XL, narrow/wide or native dp preset is implied by the local size enum.

State and variant lab

Select all nine exported variants and four sizes, then simulate pending, completion and cancellation. Disabled and full-width controls are independent. This lab does not submit a real request.

tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import { Button, type ButtonProps } from '@mobtranslate/ui';

export function ButtonActionsExample() {
  const [variant, setVariant] = useState<NonNullable<ButtonProps['variant']>>('primary');
  const [size, setSize] = useState<NonNullable<ButtonProps['size']>>('md');
  const [disabled, setDisabled] = useState(false);
  const [fullWidth, setFullWidth] = useState(false);
  const [pending, setPending] = useState(false);
  const [status, setStatus] = useState('');
  const action = useRef<HTMLElement>(null);
  useEffect(() => { if (!pending && status && !disabled) action.current?.focus(); }, [pending, status, disabled]);
  return <div data-button-lab className="space-y-4">
    <div className="grid gap-4 sm:grid-cols-2">
      <label>Action variant<select className="mt-input mt-2" value={variant} onChange={event => setVariant(event.target.value as typeof variant)}>{(['primary', 'secondary', 'destructive', 'ghost', 'outline', 'error', 'success', 'warning', 'link'] as const).map(value => <option key={value}>{value}</option>)}</select></label>
      <label>Action size<select className="mt-input mt-2" value={size} onChange={event => setSize(event.target.value as typeof size)}>{(['sm', 'md', 'lg', 'icon'] as const).map(value => <option key={value}>{value}</option>)}</select></label>
    </div>
    <label className="flex items-center gap-3 min-h-11"><input type="checkbox" checked={disabled} onChange={event => setDisabled(event.target.checked)} />Disable example action</label>
    <label className="flex items-center gap-3 min-h-11"><input type="checkbox" checked={fullWidth} onChange={event => setFullWidth(event.target.checked)} />Fill available width</label>
    <Button ref={action} data-action-button variant={variant} size={size} type="button" disabled={disabled} loading={pending} loadingText={size === 'icon' ? undefined : 'Working…'} fullWidth={fullWidth} aria-label="Run example action" onClick={() => { setPending(true); setStatus('Example pending. Choose completion or cancellation; no request was sent.'); }}>
      {size === 'icon' ? <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14M12 5v14" fill="none" stroke="currentColor" strokeWidth="2" /></svg> : 'Run example action'}
    </Button>
    {pending && <div className="flex flex-wrap gap-3"><Button type="button" onClick={() => { setPending(false); setStatus('Example completed locally. No record was published.'); }}>Complete example action</Button><Button variant="secondary" type="button" onClick={() => { setPending(false); setStatus('Example cancelled. No record was changed.'); }}>Cancel example action</Button></div>}
    <p role="status">{status}</p>
  </div>;
}

export function NativeButtonTypesExample() {
  const [submissions, setSubmissions] = useState(0);
  const [localActions, setLocalActions] = useState(0);
  return <form data-native-types className="space-y-3" onSubmit={event => { event.preventDefault(); setSubmissions(submissions + 1); }}>
    <div className="flex flex-wrap gap-3"><Button>Default native submit</Button><Button type="button" variant="secondary" onClick={() => setLocalActions(localActions + 1)}>Explicit non-submit</Button></div>
    <p role="status">{submissions} local form submissions; {localActions} non-submit actions. No request was sent.</p>
  </form>;
}

Native form behavior

Button passes type through. If you omit it on a native button inside a form, the browser’s default is submit. The package does not silently change it to button. Set type="button" for non-submit actions and type="submit" for the intended submit control. IconButton and FloatingActionButton explicitly default to button.

Do not let a cancel, disclosure or theme action accidentally submit a form. Verify native submission and validation in the consuming task, including keyboard Enter from a field.

0 local form submissions; 0 non-submit actions. No request was sent.

Composition and refs

asChild requires one React element that forwards its DOM props and ref. It preserves the child’s element semantics and uses the installed Base UI merge utilities for class/style, event composition and both refs. The child’s conflicting ordinary props and styles take precedence; disabled/loading state is enforced by the wrapper.

The child handler runs before the wrapper handler. preventDefault cancels browser behavior; it does not automatically cancel every composed callback. Disabled composed controls intercept activation before those handlers. A disabled anchor keeps its link semantics and can remain focusable, while activation is blocked.

Composed example link

The link keeps link semantics. Its browser navigation is prevented for this local demonstration.

No link activation yet.

tsx
'use client';
import { useId, useRef, useState } from 'react';
import { Button } from '@mobtranslate/ui';

export function ButtonCompositionExample() {
  const id = useId();
  const outer = useRef<HTMLElement>(null);
  const inner = useRef<HTMLAnchorElement>(null);
  const [disabled, setDisabled] = useState(false);
  const [events, setEvents] = useState<string[]>([]);
  return <div data-button-composition className="space-y-4">
    <label className="flex items-center gap-3 min-h-11"><input type="checkbox" checked={disabled} onChange={event => setDisabled(event.target.checked)} />Disable composed link</label>
    <Button asChild ref={outer} variant="secondary" disabled={disabled} aria-describedby={`${id}-help`} style={{ letterSpacing: '0.02em' }} onClickCapture={() => setEvents(current => [...current, 'wrapper capture'])} onClick={() => setEvents(current => [...current, 'wrapper'])}>
      <a ref={inner} href={`#${id}-destination`} style={{ textDecoration: 'underline' }} onClickCapture={() => setEvents(current => [...current, 'child capture'])} onClick={event => { event.preventDefault(); setEvents(current => [...current, 'child']); }}>Composed example link</a>
    </Button>
    <p id={`${id}-help`}>The link keeps link semantics. Its browser navigation is prevented for this local demonstration.</p>
    <p id={`${id}-destination`} role="status">{events.length ? `Handlers: ${events.join(', ')}. Both refs target the link: ${outer.current === inner.current}.` : 'No link activation yet.'}</p>
    <Button variant="ghost" type="button" onClick={() => setEvents([])}>Reset handler log</Button>
  </div>;
}

API reference

PropType / defaultContract
variantprimary | secondary | destructive | error | success | warning | outline | ghost | link; primaryAll nine are exported; preferred emphasis roles are primary, secondary and ghost, with destructive meaning when applicable.
sizesm | md | lg | icon; mdChooses a minimum height for text sizes and fixed geometry for icon size; the legacy link variant removes inline padding.
loadingboolean; falseShows a decorative spinner, sets aria-busy and blocks activation.
loadingTextstring; absentReplaces the displayed child content while loading. Keep the action name understandable.
disabledboolean; falseNative disabled or enforced composed-control activation blocking.
fullWidthboolean; falseAdds the full-width utility; the container still owns available space.
leftIconReactNode; absentLeading icon while not loading. Hide decorative SVG from assistive technology.
asChildboolean; falseOne prop/ref-forwarding element; preserves link vs button semantics.
childrenReactNodeVisible text/icon content, or the one composed element.
typebutton | submit | reset; absentNative browser default applies if omitted.
className / styleNative React propsMerged with component styling; inspect cascade conflicts.
onClick / ARIA / native propsReact button attributesPassed through; composed handlers use the merge contract above.
refRef<HTMLElement>Actual DOM element; a composed link is an anchor, not an HTMLButtonElement.

Behavior and accessibility

  • Use a real button for an action and a link for navigation. Visible labels and accessible names must identify the same task.
  • Native buttons activate with Enter and Space; ordinary disabled buttons leave the tab order. Do not rely on a spinner as the only pending announcement.
  • Report pending, completion, cancellation and failure with task-specific status text. A local animation is not proof that a request completed.
  • Retain a meaningful focus destination when a pending button becomes disabled or disappears. The lab returns focus after completion or cancellation.
  • Focus, meaningful boundaries and icons need their own contrast checks. Text-pair measurements alone do not certify a complete component.
  • Respect reduced motion and forced colors. Do not obscure focus with a floating control or sticky header.

Failure cases

  • Avoid several primary actions competing within one task region.
  • Do not use a green action to imply qualified language review or a red action to imply a verified destructive consequence without actual workflow evidence.
  • Do not shrink text to fit a button. Wrap or reflow the action region and inspect long translated labels.
  • Do not put a button inside another trigger button. Compose through the primitive’s supported render API.
  • Do not render an icon-only control without a meaningful name or hide the only recovery action while a request is pending.

Verification

  • Operate the lab with keyboard and pointer. Pending blocks duplicate activation; cancellation/completion announce an honest local result and restore focus.
  • Verify native button type in a form instead of relying on a documentation default.
  • Activate the composed link: child and wrapper handlers run, both refs target the same DOM element, style/description survive, and disabled activation does nothing.
  • Inspect normal, hover and pressed text colors for every variant in both themes. The warning hover keeps its readable fill and changes its border rather than darkening the fill below AA.
  • Check compact, icon and text-style targets separately, along with narrow reflow and translated labels.