Magic Native UI

Input Group

Field framed with icons, units or buttons on either side.

Installation

Terminal
npx magic-native-ui@latest add input-group

Usage

The example running in the preview above. Imports use the default aliases from components.json.

input-group-demo.tsx
import * as React from 'react'
import { Pressable, View } from 'react-native'
import { AtSign, Eye, EyeOff, Search } from 'lucide-react-native'

import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { Text } from '@/components/ui/text'
import { iconWithClassName } from '@/lib/icons'

const AtSignIcon = iconWithClassName(AtSign)
const EyeIcon = iconWithClassName(Eye)
const EyeOffIcon = iconWithClassName(EyeOff)
const SearchIcon = iconWithClassName(Search)

export function InputGroupDemo() {
  const [visible, setVisible] = React.useState(false)

  return (
    <View className="w-full gap-5">
      <InputGroup>
        <InputGroupAddon>
          <SearchIcon size={16} className="text-muted-foreground" />
        </InputGroupAddon>
        <InputGroupInput placeholder="Search components" />
      </InputGroup>

      <InputGroup>
        <InputGroupInput
          placeholder="Password"
          secureTextEntry={!visible}
          autoCapitalize="none"
        />
        <InputGroupAddon>
          <Pressable
            onPress={() => setVisible(!visible)}
            accessibilityLabel={visible ? 'Hide password' : 'Show password'}
          >
            {visible ? (
              <EyeOffIcon size={16} className="text-muted-foreground" />
            ) : (
              <EyeIcon size={16} className="text-muted-foreground" />
            )}
          </Pressable>
        </InputGroupAddon>
      </InputGroup>

      <InputGroup variant="filled">
        <InputGroupAddon>
          <Text>$</Text>
        </InputGroupAddon>
        <InputGroupInput placeholder="0.00" keyboardType="decimal-pad" />
        <InputGroupAddon>
          <Text>USD</Text>
        </InputGroupAddon>
      </InputGroup>

      <InputGroup variant="underline">
        <InputGroupAddon>
          <AtSignIcon size={16} className="text-muted-foreground" />
        </InputGroupAddon>
        <InputGroupInput placeholder="username" autoCapitalize="none" />
      </InputGroup>
    </View>
  )
}

Component source

What the CLI copies into your project. Once it is there, the code is yours to edit.

registry/ui/input-group.tsx
import * as React from 'react'
import { View, type ViewProps } from 'react-native'
import { cva, type VariantProps } from 'class-variance-authority'

import { Input, type InputProps } from '@/registry/ui/input'
import { TextClassContext } from '@/registry/ui/text'
import { cn } from '@/registry/lib/utils'

const inputGroupVariants = cva('w-full flex-row items-center gap-2', {
  variants: {
    variant: {
      default: 'rounded-md border border-input bg-background',
      filled: 'rounded-md border border-transparent bg-muted',
      underline: 'rounded-none border-b border-input bg-transparent',
    },
    size: {
      sm: 'h-9 px-2.5',
      default: 'h-10 px-3',
      lg: 'h-12 px-4',
    },
  },
  compoundVariants: [{ variant: 'underline', class: 'px-0' }],
  defaultVariants: {
    variant: 'default',
    size: 'default',
  },
})

type Size = NonNullable<VariantProps<typeof inputGroupVariants>['size']>

type InputGroupContextValue = {
  size: Size
  setFocused: (focused: boolean) => void
}

const InputGroupContext = React.createContext<InputGroupContextValue | null>(null)

function useInputGroup() {
  const context = React.useContext(InputGroupContext)

  if (!context) {
    throw new Error('InputGroup parts must be rendered inside an <InputGroup>.')
  }

  return context
}

type InputGroupProps = ViewProps &
  VariantProps<typeof inputGroupVariants> & {
    invalid?: boolean
  }

/**
 * The border lives on this container rather than on the field, so `focus:`
 * — which only ever fires on the focused element — cannot style it. The
 * nested input reports focus up through context instead.
 */
function InputGroup({ className, variant, size, invalid, ...props }: InputGroupProps) {
  const [focused, setFocused] = React.useState(false)
  const value = React.useMemo<InputGroupContextValue>(
    () => ({ size: size ?? 'default', setFocused }),
    [size]
  )

  return (
    <InputGroupContext.Provider value={value}>
      <View
        className={cn(
          inputGroupVariants({ variant, size }),
          focused && 'border-ring',
          invalid && 'border-destructive',
          className
        )}
        {...props}
      />
    </InputGroupContext.Provider>
  )
}

/** The field itself — unstyled, because the group draws the frame. */
function InputGroupInput({ className, onFocus, onBlur, ...props }: InputProps) {
  const { size, setFocused } = useInputGroup()

  return (
    <Input
      variant="unstyled"
      size={size}
      className={cn('h-full flex-1', className)}
      onFocus={(event) => {
        setFocused(true)
        onFocus?.(event)
      }}
      onBlur={(event) => {
        setFocused(false)
        onBlur?.(event)
      }}
      {...props}
    />
  )
}

/** Anything beside the field: an icon, a unit, a button. */
function InputGroupAddon({ className, ...props }: ViewProps) {
  return (
    <TextClassContext.Provider value="text-sm text-muted-foreground">
      <View className={cn('flex-row items-center gap-1.5', className)} {...props} />
    </TextClassContext.Provider>
  )
}

export { InputGroup, InputGroupAddon, InputGroupInput, inputGroupVariants }
export type { InputGroupProps }