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 JsonInputPropsextends 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 (<Textareaautosizevalue={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
orclassNames
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 ColorInputPropsextends 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) => (<ColorSwatchstyle={{ cursor: 'pointer' }}key={color}component="button"color={color}onClick={() => handleChange(color)}/>));return (<InputWrapperrequired={required}id={uuid}label={label}error={error}description={description}><div style={{ position: 'relative' }}><Inputcomponent="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><Transitiontransition="skew-up"duration={250}mounted={dropdownOpened}// Wait for focus trap to unmount and focus controlonExited={() => setTimeout(() => controlRef.current.focus(), 10)}>{(transitionStyles) => (<Papershadow="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:
<Inputcomponent="button"onClick={() => setDropdownOpened(true)}inputStyle={{ cursor: 'pointer' }}elementRef={controlRef}{...others}>{/*Since Input is rendered as a buttonwe 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 generatedconst 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 propconst colors = data.map((color) => (<ColorSwatchkey={color}// make color swatch interactive, focus styles from theme are already appliedcomponent="button"color={color}onClick={() => handleChange(color)}style={{ cursor: 'pointer' }}/>));const dropdown = (<Paper// predefined shadow and padding from theme.shadows and theme.spacingshadow="md"padding="md"// get element ref for focus trap and click outsideelementRef={dropdownRef}// Close dropdown when user presses escape// since focus is trapped inside we do not need to pollute window with this eventonKeyDownCapture={(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:
<Transitiontransition="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.