Spin button
View .mdA spin button is a specialized input that lets someone easily increase or decrease a value within a set range, like dates in a month. Spin buttons are good for precise increments in small ranges.
Default
Loading interactive example. Full source is available below.
View example 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.
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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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;