Blog

31st.fr logo
Blog's main page
EN

The goal is to create a custom component based on the excellent https://ui.shadcn.com/ library, allowing you to attach one or more actions to an input field of type "text" or "number".

The attached buttons can be displayed before, after, or both before and after the input field.

You can find a fully functional example using the following repository: https://github.com/31stfr/shadcnui-input-with-buttons

Project initialization

Let's start with a common React project initialization based on Vite, Tailwind, Shadcn/ui and React icons. For brevity's sake, i do not detail the entire configuration of each library.

init.sh
# VITE
# https://vite.dev/guide/
yarn create vite shadcnui-input-with-buttons --template react-ts

# TAILWIND
# https://tailwindcss.com/docs/installation/using-vite
yarn add tailwindcss @tailwindcss/vite

# SHADCN/UI 
# https://ui.shadcn.com/docs/installation/vite
# Follow the required configuration, then run:
npx shadcn@latest init

npx shadcn@latest add input
npx shadcn@latest add button
npx shadcn@latest add label

# REACT ICONS
# https://react-icons.github.io/react-icons/
yarn add react-icons --save

Creating the custom component

Create a file named InputWithButtons.tsx in the following path: src/components/ui/custom/InputWithButtons.tsx.

The ui directory from Shadcn is used to host our custom components.

We'll define the component's TypeScript props using an interface that extends React's native input props. This way, our custom component will seamlessly accept all the default props of a React input, just like Shadcn's components.

We also add the following custom properties:

className: for styling the entire component

prefixButtons: an optional array of buttons rendered before the input

suffixButtons: an optional array of buttons rendered after the input

The prefixButtons and suffixButtons props are arrays of a custom ButtonSettings interface.

The ButtonSettings interface extends React's button props and supports Shadcn's button variants to ensure consistent styles. Each button includes an icon property and a callback function triggered on click.

To ensure a seamless UI, we also customize the input's CSS to make the corners square on the appropriate side(s) using a variable called inputCss. This ensures the component looks clean when buttons are attached before, after, or on both sides of the input.

src/components/ui/custom/InputWithButtons.tsx
import type { VariantProps } from 'class-variance-authority';
import type { ComponentProps, ReactElement } from 'react';
import { twMerge } from 'tailwind-merge';
import type { buttonVariants } from '../button';
import { Input } from '../input';

// Buttons' type
interface ButtonSettings extends ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
    icon: ReactElement;
}

// "InputWithButtons" component props
interface InputWithButtonsProps extends ComponentProps<'input'> {
    className?: string;
    prefixButtons?: ButtonSettings[];
    suffixButtons?: ButtonSettings[];
}

const InputWithButtons = ({
    prefixButtons = undefined,
    suffixButtons = undefined,
    className = undefined,
    ...rest
}: InputWithButtonsProps) => {
    const inputCss = twMerge(
        'flex-1',
        prefixButtons ? 'rounded-l-none' : undefined,
        suffixButtons ? 'rounded-r-none' : undefined
    );

    return (
        <div className={twMerge('flex flex-wrap', className)}>
            <Input {...rest} className={inputCss} />
        </div>
    );
};

export default InputWithButtons;

Add buttons management to our custom component

So far, our component only renders the input field. Let's render the attached buttons as well.

To do this, we'll create a reusable component named ParsedButtons. Its TypeScript interface is straightforward:

buttonSettings: receives the list of buttons (passed from InputWithButtons)

parsingSettings: defines the rendering logic differences between prefix and suffix buttons

src/components/ui/custom/InputWithButtons.tsx
import { Input } from '@/components/ui/input';
import type { VariantProps } from 'class-variance-authority';
import { type ComponentProps, type ReactElement } from 'react';
import { twMerge } from 'tailwind-merge';
import { Button, buttonVariants } from '../button';

// Buttons' type
interface ButtonSettings extends ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
    icon: ReactElement;
}

// "InputWithButtons" component props
interface InputWithButtonsProps extends ComponentProps<'input'> {
    className?: string;
    prefixButtons?: ButtonSettings[];
    suffixButtons?: ButtonSettings[];
}

// "ParsedButtons" component props
interface ParsedButtonsProps {
    buttonSettings?: ButtonSettings[];
    parsingSettings: typeof prefixSettings | typeof suffixSettings;
}

// Settings for parsing prefix buttons
const prefixSettings = {
    className: 'rounded-r-none',
    extremityCheck: (index: number) => index === 0,
    extremityClassName: 'rounded-l-none',
    key: 'prefix-button-',
} as const;

// Settings for parsing suffix buttons
const suffixSettings = {
    extremityClassName: 'rounded-r-none',
    className: 'rounded-l-none',
    key: 'suffix-button-',
    extremityCheck: (index: number, length: number) => index === length - 1,
} as const;

// "ParsedButtons" component to render button list based on settings
const ParsedButtons = ({ buttonSettings, parsingSettings }: ParsedButtonsProps) => {
    if (!buttonSettings?.length) return undefined;

    return buttonSettings.map((currentButton, index) => {
        const isExtremity = parsingSettings.extremityCheck(index, buttonSettings.length);

        return (
            <Button
                key={`${parsingSettings.key}-${index}`}
                {...currentButton}
                type={currentButton.type ?? 'button'}
                className={twMerge(
                    parsingSettings.className,
                    isExtremity ? '' : parsingSettings.extremityClassName
                )}
            >
                {currentButton.icon}
            </Button>
        );
    });
};

// Shadcn input component with buttons
const InputWithButtons = ({
    prefixButtons = undefined,
    suffixButtons = undefined,
    className = undefined,
    ...rest
}: InputWithButtonsProps) => {
    const inputCss = twMerge(
        'flex-1',
        prefixButtons ? 'rounded-l-none' : undefined,
        suffixButtons ? 'rounded-r-none' : undefined
    );

    return (
        <div className={twMerge('flex flex-wrap', className)}>
            <ParsedButtons buttonSettings={prefixButtons} parsingSettings={prefixSettings} />
            <Input {...rest} className={inputCss} />
            <ParsedButtons buttonSettings={suffixButtons} parsingSettings={suffixSettings} />
        </div>
    );
};

export default InputWithButtons;

Nice ! Our component is now fully functional.

Using the InputWithButtons Component

Using the component is simple: Let's invoque it in the App.tsx file with a list of ButtonSettings objects containings icons and callback functions.

src/App.tsx
import { FaMinus, FaPlus } from 'react-icons/fa6';
import InputWithButtons from './components/ui/custom/InputWithButtons';

const App = () => {
    return (
        <div className="flex flex-col gap-4">
            <InputWithButtons
                prefixButtons={[
                    {
                        icon: <FaPlus />,
                        onClick: () => alert('Button clicked!'),
                    },
                    {
                        icon: <FaMinus />,
                        onClick: () => alert('Button clicked!'),
                    },
                ]}
                name="input-01"
                type="text"
            />
            <InputWithButtons
                suffixButtons={[
                    {
                        icon: <FaPlus />,
                        onClick: () => alert('Button clicked!'),
                    },
                    {
                        icon: <FaMinus />,
                        onClick: () => alert('Button clicked!'),
                    },
                ]}
                name="input-02"
                type="text"
            />
        </div>
    );
};

export default App;

I engage you to have a look at this repository to get more realistic examples. Have fun and feel free to customize this component as much as needed, in line with the philosophy of Shadcn/ui.