Building custom inputs

@mantine/core and @mantine/hooks come with all utilities that you need to build custom inputs. These examples with provide a reference on how to enhance existing components with extra logic to fit your needs and how to use mantine packages to create completely new accessible inputs based on Input and InputWrapper components.

Wrapped Textarea

First option to create custom input is to enhance existing Mantine component with extra logic and styles. In this example we will build JsonInput component that adds additional styles and validation logic to Textarea.

JsonInput uses autosize variant of Textarea component, which accepts json, validates and formats it.

import React, { useState } from 'react';
import { Textarea, useMantineTheme } from '@mantine/core';
interface JsonInputProps
extends Omit<React.ComponentPropsWithoutRef<typeof Textarea>, 'error' | 'onChange'> {
value: string;
onChange(value: string): void;
}
export function JsonInput({ value, onChange, onBlur, onFocus, ...others }: JsonInputProps) {
const [valid, setValid] = useState(true);
const theme = useMantineTheme();
return (
<Textarea
autosize
value={value}
minRows={2}
error={!valid && 'Invalid json'}
styles={{
input: {
fontFamily: theme.fontFamilyMonospace,
fontSize: theme.fontSizes.xs,
},
}}
onChange={(event) => onChange(event.currentTarget.value)}
onFocus={(event) => {
setValid(true);
typeof onFocus === 'function' && onFocus(event);
}}
onBlur={(event) => {
typeof onBlur === 'function' && onBlur(event);
try {
onChange(JSON.stringify(JSON.parse(event.currentTarget.value), null, 2));
} catch (e) {
setValid(false);
}
}}
{...others}
/>
);
}

Key parts:

  • Use styles or classNames to apply styles to any element (styles applied to input in this example)
  • onChange, onFocus, onBlur and all other input related props go directly to input element
  • Use typeof Textarea to get Textarea component props in TypeScript

Custom input with dropdown

Second option to create custom input is to build everything from scratch. In this example we will utilize Mantine components and hooks to create accessible color picker component.

ColorInput is a custom input built with Input and InputWrapper components.

import React, { useState, useRef } from 'react';
import { useId, useMergedRef, useClickOutside, useFocusTrap } from '@mantine/hooks';
import {
useMantineTheme,
InputWrapper,
Input,
InputProps,
InputWrapperBaseProps,
Transition,
Paper,
Group,
ColorSwatch,
Text,
} from '@mantine/core';
interface ColorInputProps
extends InputProps,
InputWrapperBaseProps,
Omit<React.ComponentPropsWithoutRef<'button'>, 'value' | 'onChange'> {
value: string;
onChange(color: string): void;
data: string[];
}
export function ColorInput({
id,
value,
placeholder,
onChange,
data,
required,
description,
label,
error,
...others
}: ColorInputProps) {
const theme = useMantineTheme();
const uuid = useId(id);
const controlRef = useRef<HTMLButtonElement>();
const [dropdownOpened, setDropdownOpened] = useState(false);
const focusTrapRef = useFocusTrap();
const closeDropdown = () => setDropdownOpened(false);
const clickOutsideRef = useClickOutside(closeDropdown);
const dropdownRef = useMergedRef(focusTrapRef, clickOutsideRef);
const handleChange = (color: string) => {
onChange(color);
closeDropdown();
};
const colors = data.map((color) => (
<ColorSwatch
style={{ cursor: 'pointer' }}
key={color}
component="button"
color={color}
onClick={() => handleChange(color)}
/>
));
return (
<InputWrapper
required={required}
id={uuid}
label={label}
error={error}
description={description}
>
<div style={{ position: 'relative' }}>
<Input
component="button"
id={uuid}
onClick={() => setDropdownOpened(true)}
styles={{ input: { cursor: 'pointer' } }}
elementRef={controlRef}
{...others}
>
{value ? (
<div style={{ display: 'flex', alignItems: 'center' }}>
<ColorSwatch color={value} size={16} style={{ marginRight: 10 }} />
<Text size="sm" transform="uppercase" style={{ lineHeight: 1 }}>
{value}
</Text>
</div>
) : (
<Text color="gray" size="sm">
{placeholder}
</Text>
)}
</Input>
<Transition
transition="skew-up"
duration={250}
mounted={dropdownOpened}
// Wait for focus trap to unmount and focus control
onExited={() => setTimeout(() => controlRef.current.focus(), 10)}
>
{(transitionStyles) => (
<Paper
shadow="md"
padding="md"
elementRef={dropdownRef}
style={{
...transitionStyles,
position: 'absolute',
top: 0,
left: 0,
right: 0,
border: `1px solid ${
theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[2]
}`,
}}
onKeyDownCapture={(event) => {
if (event.nativeEvent.code === 'Escape') {
closeDropdown();
}
}}
>
<Group position="center">{colors}</Group>
</Paper>
)}
</Transition>
</div>
</InputWrapper>
);
}

Input

For this input we will use Input as button, as we do not want to allow any free user input. We will also grab button ref for future focus management:

<Input
component="button"
onClick={() => setDropdownOpened(true)}
inputStyle={{ cursor: 'pointer' }}
elementRef={controlRef}
{...others}
>
{/*
Since Input is rendered as a button
we can use children to display current value or placeholder
*/}
<div style={{ display: 'flex', alignItems: 'center' }}>
<ColorSwatch color={value} size={20} style={{ marginRight: 10 }} />
<Text size="sm" transform="uppercase">
{value}
</Text>
</div>
</Input>

InputWrapper

To give ColorInput component the same label, description and error props as in other Mantine inputs, we will wrap it with InputWrapper and ensure that label is connected to input with use-id hook:

// if input receives id from props, this id will be used,
// otherwise random id will be generated
const uuid = useId(id);
// We just set InputWrapper props from ColorInput props
// It's not a rocket science as you see
<InputWrapper required={required} id={uuid} label={label} error={error} description={description}>
<Input id={uuid} /* other input props */ />
</InputWrapper>;

Dropdown

Dropdown is built with Paper and ColorSwatch components.

// Colors generated from data prop
const colors = data.map((color) => (
<ColorSwatch
key={color}
// make color swatch interactive, focus styles from theme are already applied
component="button"
color={color}
onClick={() => handleChange(color)}
style={{ cursor: 'pointer' }}
/>
));
const dropdown = (
<Paper
// predefined shadow and padding from theme.shadows and theme.spacing
shadow="md"
padding="md"
// get element ref for focus trap and click outside
elementRef={dropdownRef}
// Close dropdown when user presses escape
// since focus is trapped inside we do not need to pollute window with this event
onKeyDownCapture={(event) => {
if (event.nativeEvent.code === 'Escape') {
closeDropdown();
}
}}
>
<Group position="center">{colors}</Group>
</Paper>
);

Click outside and focus trap

When dropdown is opened usually it is a good idea to trap focus inside and close it with outside clicks. To implement this use use-click-outside and use-focus-trap. Both hooks return ref that should be passed to dropdown, to combine them use use-merged-ref hook:

const focusTrapRef = useFocusTrap();
const clickOutsideRef = useClickOutside(closeDropdown);
const dropdownRef = useMergedRef(focusTrapRef, clickOutsideRef);
// on dropdown component
<Paper elementRef={dropdownRef} /* ...other dropdown props */ />;

Animations

To animate dropdown presence we will use Transition component, it has some premade transitions, for this example skew-up will do the job:

<Transition
transition="skew-up"
duration={250}
mounted={dropdownOpened}
onExited={() => setTimeout(() => controlRef.current.focus(), 10)}
>
{(transitionStyles) => <Paper style={transitionStyles} /* ...other dropdown props */ />}
</Transition>

When dropdownOpened is false, dropdown will not be mounted to the dom – focus trap will have no effect and click outside events will not be registered. When dropdown transition is finished we move focus back to control with onExit callback.

Build fully functional accessible web applications with ease
Feedback
Your feedback is most valuable contribution to the project, please share how you use Mantine, what features are missing and what is done good
Leave feedback