Default

Loading interactive example. Full source is available below.

View example source
Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { useId } from "@coreai-ds/react-components";
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";
import * as React from 'react';

export const Default = () => {
    const id = useId();
    const styles = /* css */ `
      .spin-button-default-example {
        display: flex;
        flex-direction: column;
        max-width: 500px;
      }

      .spin-button-default-example > .spin-button-default-example__label {
        margin-bottom: var(--spacingVerticalXXS);
      }
    `;
    return (<>
        <style>{styles}</style>
        <div className="spin-button-default-example">
          <Label className="spin-button-default-example__label" htmlFor={id}>
            Default SpinButton
          </Label>
          <SpinButton defaultValue={10} min={0} max={20} id={id}/>
        </div>
      </>);
};

export default Default;

Resources

Best practices

Do

  • Use spin buttons for precise increments within small ranges.
  • Choose increments that are reasonable for the represented value.
  • Keep the input enabled when a boundary disables an increment button.

Don't

  • Use a spin button for very large ranges; use an input.
  • Use it for fewer than three values; use an input.
  • Use it when values may be imprecise and visual position matters; use a slider.

Behavior and states

  • Use spin buttons for precise increments in small ranges.
  • Use an input instead when the range is very large or has fewer than three values.
  • Use a slider when values can be imprecise and a visual representation of the range is helpful.
  • If the range is very large or very small (less than three values), use an input instead.
  • If the values can be imprecise and a visual representation of the value on the range is helpful, try a slider.
API referenceGenerated CoreAI prop and type summaries

API

Generated CoreAI prop summaries, not replacement type declarations. Most native DOM/event and ARIA props are omitted. ? means optional; JSDoc shows defaults, deprecations, and conditional requirements. Defaults assume the standard CoreAI provider.

SpinButton API

interface SpinButtonProps {
  /**
   * Controls the colors and borders of the input.
   * @default 'filled-darker'
   */
  appearance?: "outline" | "filled-darker" | "filled-lighter" | "underline";

  as?: "input";

  children?: undefined;

  className?: string;

  /**
   * Renders the decrement control.
   */
  decrementButton?: NonNullable<WithSlotShorthandValue<{ as?: "button"; } & Omit<Omit<DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "ref"> & { ref?: ((instance: HTMLButtonElement | null) => void) | RefObject<HTMLButtonElement> | null; }, "children"> & { children?: any; }> | null> | undefined;

  /**
   * Initial value of the control (assumed to be valid). Updates to this prop will not be respected.
   *
   * Use this if you intend for the SpinButton to be an uncontrolled component which maintains its
   * own value. For a controlled component, use `value` instead. (Mutually exclusive with `value`.)
   *
   * Use `null` to indicate the control has no value.
   */
  defaultValue?: number | null;

  /**
   * String representation of `value`.
   *
   * Use this when displaying the value to users as something other than a plain number.
   * For example, when displaying currency values this might be "$1.00" when value is `1`.
   *
   * Only provide this if the SpinButton is a controlled component where you are maintaining its
   * current state and passing updates based on change events. When SpinButton is used as an
   * uncontrolled component this prop is ignored.
   */
  displayValue?: string;

  /**
   * Renders the increment control.
   */
  incrementButton?: NonNullable<WithSlotShorthandValue<{ as?: "button"; } & Omit<Omit<DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>, "ref"> & { ref?: ((instance: HTMLButtonElement | null) => void) | RefObject<HTMLButtonElement> | null; }, "children"> & { children?: any; }> | null> | undefined;

  /**
   * Input that displays the current value and accepts direct input from the user.
   * Displayed value is formatted.
   *
   * This is the primary slot.
   */
  input?: ({ as?: "input"; } & Omit<Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "ref"> & { ref?: ((instance: HTMLInputElement | null) => void) | RefObject<HTMLInputElement> | null; }, "children"> & { children?: undefined; }) | undefined;

  /**
   * Max value of the control. If not provided, the control has no maximum value.
   */
  max?: number;

  /**
   * Min value of the control. If not provided, the control has no minimum value.
   */
  min?: number;

  /**
   * Callback for when the committed value changes.
   * - User presses the up/down buttons (on single press or every spin)
   * - User presses the up/down arrow keys (on single press or every spin)
   * - User *commits* edits to the input text by focusing away (blurring) or pressing enter.
   *   Note that this is NOT called for every key press while the user is editing.
   */
  onChange?: ((event: SpinButtonChangeEvent, data: SpinButtonOnChangeData) => void);

  /**
   * How many decimal places the value should be rounded to.
   *
   * The default is calculated based on the precision of `step`: i.e. if step = 1, precision = 0.
   * step = 0.0089, precision = 4. step = 300, precision = 2. step = 23.00, precision = 2.
   */
  precision?: number;

  ref?: Ref<HTMLInputElement>;

  /**
   * The root element of SpinButton is a container `<div>`.
   * The root slot receives the `className` and `style` specified on the `<SpinButton>`.
   * All other native props are applied to the primary slot: `input`.
   */
  root?: NonNullable<WithSlotShorthandValue<{ as?: "span"; } & Omit<Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "ref"> & { ref?: ((instance: HTMLSpanElement | null) => void) | RefObject<HTMLSpanElement> | null; }, "children"> & { children?: any; }> | null> | undefined;

  /**
   * Size of the input.
   * @default 'medium'
   */
  size?: "small" | "medium";

  /**
   * Difference between two adjacent values of the control.
   * This value is used to calculate the precision of the input if no `precision` is given.
   * The precision calculated this way will always be greater than or equal 0.
   * @default 1
   */
  step?: number;

  /**
   * Large difference between two values. This should be greater than `step` and is used
   * when users hit the Page Up or Page Down keys.
   * @default 1
   */
  stepPage?: number;

  style?: CSSProperties;

  /**
   * Current value of the control (assumed to be valid).
   *
   * Only provide this if the SpinButton is a controlled component where you are maintaining its
   * current state and passing updates based on change events; otherwise, use the `defaultValue`
   * property.
   *
   * Use `null` to indicate the control has no value.
   *
   * Mutually exclusive with `defaultValue`.
   */
  value?: number | null;
}

Implementation

CoreAI 0.12.0; Fluent 9.74.9.

Setup and dependencies.

Import components from @coreai-ds/react-components. CoreAI guidance above takes precedence over API demonstration examples.

Appearance

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { useId } from "@coreai-ds/react-components";
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";
import { mergeClasses } from "@coreai-ds/react-components";
import * as React from 'react';

const useStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
        maxWidth: '500px',
    },
    field: {
        display: 'grid',
        gridRowGap: tokens.spacingVerticalXXS,
        marginTop: tokens.spacingVerticalMNudge,
        padding: tokens.spacingHorizontalMNudge,
    },
    filledLighter: {
        backgroundColor: tokens.colorNeutralBackgroundInverted,
        '> label': {
            color: tokens.colorNeutralForegroundInverted2,
        },
    },
    filledDarker: {
        backgroundColor: tokens.colorNeutralBackgroundInverted,
        '> label': {
            color: tokens.colorNeutralForegroundInverted2,
        },
    },
});

export const Appearance = (): JSXElement => {
    const styles = useStyles();
    const outlineId = useId('outline-id');
    const underlineId = useId('underline-id');
    const filledLighterId = useId('filledLighter-id');
    const filledDarkerId = useId('filledDarker-id');
    return (<div className={styles.base}>
      <div className={styles.field}>
        <Label htmlFor={outlineId}>Outline (default)</Label>
        <SpinButton id={outlineId}/>
      </div>

      <div className={styles.field}>
        <Label htmlFor={underlineId}>Underline</Label>
        <SpinButton appearance="underline" id={underlineId}/>
      </div>

      <div className={mergeClasses(styles.field, styles.filledLighter)}>
        <Label htmlFor={filledLighterId}>Filled Lighter</Label>
        <SpinButton appearance="filled-lighter" id={filledLighterId}/>
      </div>

      <div className={mergeClasses(styles.field, styles.filledDarker)}>
        <Label htmlFor={filledDarkerId}>Filled Darker</Label>
        <SpinButton appearance="filled-darker" id={filledDarkerId}/>
      </div>
    </div>);
};

export default Appearance;

Bounds

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { useId } from "@coreai-ds/react-components";
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";
import * as React from 'react';

const useLayoutStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
        maxWidth: '500px',
        '> label': {
            marginBottom: tokens.spacingVerticalXXS,
        },
    },
});

export const Bounds = (): JSXElement => {
    const layoutStyles = useLayoutStyles();
    const id = useId();
    return (<div className={layoutStyles.base}>
      <Label htmlFor={id}>Bounded SpinButton</Label>
      <SpinButton defaultValue={10} min={0} max={20} id={id}/>
      <p>min: 0, max: 20</p>
    </div>);
};

export default Bounds;

Controlled

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { useId } from "@coreai-ds/react-components";
import * as React from 'react';
import type { SpinButtonChangeEvent } from "@coreai-ds/react-components";
import type { SpinButtonOnChangeData } from "@coreai-ds/react-components";
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";

const useLayoutStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
        maxWidth: '500px',
        '> label': {
            marginBottom: tokens.spacingVerticalXXS,
        },
    },
});

export const Controlled = (): JSXElement => {
    const layoutStyles = useLayoutStyles();
    const id = useId();
    const [spinButtonValue, setSpinButtonValue] = React.useState<number | null>(10);
    const onSpinButtonChange = React.useCallback((_ev: SpinButtonChangeEvent, data: SpinButtonOnChangeData) => {
        console.log('onSpinButtonChange', data.value, data.displayValue);
        if (data.value !== undefined) {
            setSpinButtonValue(data.value);
        }
        else if (data.displayValue !== undefined) {
            const newValue = parseFloat(data.displayValue);
            if (!Number.isNaN(newValue)) {
                setSpinButtonValue(newValue);
            }
            else {
                console.error(`Cannot parse "${data.displayValue}" as a number.`);
            }
        }
    }, [setSpinButtonValue]);
    return (<div className={layoutStyles.base}>
      <Label htmlFor={id}>Controlled SpinButton</Label>
      <SpinButton value={spinButtonValue} onChange={onSpinButtonChange} id={id}/>
    </div>);
};

export default Controlled;

Disabled

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { useId } from "@coreai-ds/react-components";
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";
import * as React from 'react';

const useLayoutStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
        maxWidth: '500px',
        '> label': {
            marginBottom: tokens.spacingVerticalXXS,
        },
    },
});

export const Disabled = (): JSXElement => {
    const layoutStyles = useLayoutStyles();
    const id = useId();
    return (<div className={layoutStyles.base}>
      <Label htmlFor={id}>Disabled</Label>
      <SpinButton disabled id={id}/>
    </div>);
};

export default Disabled;

Display Value

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import type { SpinButtonProps } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { useId } from "@coreai-ds/react-components";
import * as React from 'react';
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";

type FormatterFn = (value: number) => string;

type ParserFn = (formattedValue: string) => number;

const useLayoutStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
        maxWidth: '500px',
        '> label': {
            marginBottom: tokens.spacingVerticalXXS,
        },
    },
});

export const DisplayValue = (): JSXElement => {
    const formatter: FormatterFn = value => {
        return `$${value}`;
    };
    const parser: ParserFn = formattedValue => {
        if (formattedValue === null) {
            return NaN;
        }
        return parseFloat(formattedValue.replace('$', ''));
    };
    const onSpinButtonChange: SpinButtonProps['onChange'] = (_ev, data) => {
        if (data.value !== undefined && data.value !== null) {
            setSpinButtonValue(data.value);
            setSpinButtonDisplayValue(formatter(data.value));
        }
        else if (data.displayValue !== undefined) {
            const newValue = parser(data.displayValue);
            if (!Number.isNaN(newValue)) {
                setSpinButtonValue(newValue);
                setSpinButtonDisplayValue(formatter(newValue));
            }
            else {
                // Display a "special" value when user types something
                // that's not parsable as a number.
                setSpinButtonValue(null);
                setSpinButtonDisplayValue('(null)');
            }
        }
    };
    const layoutStyles = useLayoutStyles();
    const id = useId();
    const [spinButtonValue, setSpinButtonValue] = React.useState<number | null>(1);
    const [spinButtonDisplayValue, setSpinButtonDisplayValue] = React.useState(formatter(1));
    return (<div className={layoutStyles.base}>
      <Label htmlFor={id}>Display Value</Label>
      <SpinButton value={spinButtonValue} displayValue={spinButtonDisplayValue} onChange={onSpinButtonChange} id={id}/>
    </div>);
};

export default DisplayValue;

Read Only

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { useId } from "@coreai-ds/react-components";
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";
import * as React from 'react';

const useLayoutStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
        maxWidth: '500px',
        '> label': {
            marginBottom: tokens.spacingVerticalXXS,
        },
    },
});

export const ReadOnly = (): JSXElement => {
    const layoutStyles = useLayoutStyles();
    const id = useId();
    return (<div className={layoutStyles.base}>
      <Label htmlFor={id}>Read-Only</Label>
      <SpinButton readOnly id={id}/>
    </div>);
};

export default ReadOnly;

Size

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { useId } from "@coreai-ds/react-components";
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";
import * as React from 'react';

const useLayoutStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
        maxWidth: '500px',
        '> div': {
            display: 'flex',
            flexDirection: 'column',
            marginTop: tokens.spacingHorizontalMNudge,
        },
        '> div:first-child': {
            marginTop: '0px',
        },
        '> div label': {
            marginBottom: tokens.spacingVerticalXXS,
        },
    },
});

export const Size = (): JSXElement => {
    const layoutStyles = useLayoutStyles();
    const smallId = useId('small-id');
    const mediumId = useId('medium-id');
    return (<div className={layoutStyles.base}>
      <div>
        <Label htmlFor={smallId}>Small</Label>
        <SpinButton size="small" id={smallId}/>
      </div>

      <div>
        <Label htmlFor={mediumId}>Medium (default)</Label>
        <SpinButton id={mediumId}/>
      </div>
    </div>);
};

export default Size;

Step

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { useId } from "@coreai-ds/react-components";
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";
import * as React from 'react';

const useLayoutStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
        maxWidth: '500px',
        '> label': {
            marginBottom: tokens.spacingVerticalXXS,
        },
    },
});

export const Step = (): JSXElement => {
    const layoutStyles = useLayoutStyles();
    const id = useId();
    return (<div className={layoutStyles.base}>
      <Label htmlFor={id}>Step Size</Label>
      <SpinButton defaultValue={10} step={2} stepPage={20} id={id}/>
    </div>);
};

export default Step;

Uncontrolled

Loading interactive example. Full source is available below.

Download source
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import type { JSXElement } from "@coreai-ds/react-components";
import { makeStyles } from "@coreai-ds/react-components";
import { tokens } from "@coreai-ds/react-components";
import { useId } from "@coreai-ds/react-components";
import { Label } from "@coreai-ds/react-components";
import { SpinButton } from "@coreai-ds/react-components";
import * as React from 'react';

const useLayoutStyles = makeStyles({
    base: {
        display: 'flex',
        flexDirection: 'column',
        maxWidth: '500px',
        '> label': {
            marginBottom: tokens.spacingVerticalXXS,
        },
    },
});

export const Uncontrolled = (): JSXElement => {
    const layoutStyles = useLayoutStyles();
    const id = useId();
    return (<div className={layoutStyles.base}>
      <Label htmlFor={id}>Uncontrolled SpinButton</Label>
      <SpinButton defaultValue={10} id={id}/>
    </div>);
};

export default Uncontrolled;