# iOS Kit

> iOS interface kit: device frame, large-title navigation bar, tab bar, grouped lists, switch, segmented control, bottom sheet and app icons, with light and dark support.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/ios-kit
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/ios-kit.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/ios-kit-demo.tsx"
"use client"

import * as React from "react"
import { Bell, Moon, Plane, Wifi } from "lucide-react"

import { IOSButton, IOSGroup, IOSRow, IOSSegmented, IOSSwitch } from "@/components/ios-kit"

export default function IOSKitDemo() {
  const [airplane, setAirplane] = React.useState(false)
  const [focus, setFocus] = React.useState(true)
  const [segment, setSegment] = React.useState(0)

  return (
    <div className="flex w-full justify-center bg-neutral-100 p-8 dark:bg-neutral-900">
      <div className="w-full max-w-sm [--ios-bg:#f2f2f7] [--ios-blue:#007aff] [--ios-fill:#7878801f] [--ios-green:#34c759] [--ios-label:#000] [--ios-label2:#3c3c4399] [--ios-label3:#3c3c434d] [--ios-orange:#ff9500] [--ios-purple:#af52de] [--ios-sep:#3c3c434a] [--ios-surface:#fff] dark:[--ios-bg:#000] dark:[--ios-blue:#0a84ff] dark:[--ios-fill:#7878803d] dark:[--ios-green:#30d158] dark:[--ios-label:#fff] dark:[--ios-label2:#ebebf599] dark:[--ios-label3:#ebebf54d] dark:[--ios-orange:#ff9f0a] dark:[--ios-purple:#bf5af2] dark:[--ios-sep:#54545aa6] dark:[--ios-surface:#1c1c1e]">
        <div className="rounded-3xl bg-(color:--ios-bg) py-4 text-(color:--ios-label)">
          <IOSSegmented options={["Day", "Week", "Month"]} value={segment} onValueChange={setSegment} className="mx-4 mb-5" />
          <IOSGroup header="Connectivity" footer="Rows, switches and groups follow the iOS settings layout.">
            <IOSRow
              icon={<Plane />}
              iconClassName="bg-(color:--ios-orange)"
              title="Airplane Mode"
              right={<IOSSwitch checked={airplane} onCheckedChange={setAirplane} label="Airplane Mode" />}
            />
            <IOSRow icon={<Wifi />} title="Wi-Fi" detail="Atelier" chevron onClick={() => {}} />
            <IOSRow
              icon={<Moon />}
              iconClassName="bg-(color:--ios-purple)"
              title="Focus"
              right={<IOSSwitch checked={focus} onCheckedChange={setFocus} label="Focus" />}
            />
            <IOSRow icon={<Bell />} iconClassName="bg-(color:--ios-green)" title="Notifications" chevron onClick={() => {}} />
          </IOSGroup>
          <div className="px-4">
            <IOSButton className="w-full">Continue</IOSButton>
          </div>
        </div>
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/ios-kit.tsx"
"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, Search } from "lucide-react"

import { cn } from "@/lib/utils"

/* iOS system palette, switched by the `dark` class. Everything below reads these variables. */
const palette = [
  "[--ios-bg:#f2f2f7] dark:[--ios-bg:#000000]",
  "[--ios-surface:#ffffff] dark:[--ios-surface:#1c1c1e]",
  "[--ios-surface2:#f2f2f7] dark:[--ios-surface2:#2c2c2e]",
  "[--ios-label:#000000] dark:[--ios-label:#ffffff]",
  "[--ios-label2:#3c3c4399] dark:[--ios-label2:#ebebf599]",
  "[--ios-label3:#3c3c434d] dark:[--ios-label3:#ebebf54d]",
  "[--ios-sep:#3c3c434a] dark:[--ios-sep:#54545aa6]",
  "[--ios-fill:#7878801f] dark:[--ios-fill:#7878803d]",
  "[--ios-blue:#007aff] dark:[--ios-blue:#0a84ff]",
  "[--ios-green:#34c759] dark:[--ios-green:#30d158]",
  "[--ios-red:#ff3b30] dark:[--ios-red:#ff453a]",
  "[--ios-orange:#ff9500] dark:[--ios-orange:#ff9f0a]",
  "[--ios-yellow:#ffcc00] dark:[--ios-yellow:#ffd60a]",
  "[--ios-pink:#ff2d55] dark:[--ios-pink:#ff375f]",
  "[--ios-purple:#af52de] dark:[--ios-purple:#bf5af2]",
  "[--ios-teal:#5ac8fa] dark:[--ios-teal:#64d2ff]",
  "[--ios-indigo:#5856d6] dark:[--ios-indigo:#5e5ce6]",
].join(" ")

const font =
  "font-[-apple-system,BlinkMacSystemFont,'SF_Pro_Text','SF_Pro_Display','PingFang_SC','Helvetica_Neue',sans-serif] antialiased"

/* ---------------------------------------------------------------- device */

function StatusBar({ light }: { light?: boolean }) {
  return (
    <div
      aria-hidden
      className={cn(
        "pointer-events-none absolute inset-x-0 top-0 z-50 flex h-[54px] items-start justify-between px-8 pt-[19px] text-[16px] leading-none font-semibold tracking-tight",
        light ? "text-white" : "text-(color:--ios-label)"
      )}
    >
      <span className="w-[54px] text-center">9:41</span>
      <span className="flex items-center gap-[6px]">
        <span className="flex h-[11px] items-end gap-[2px]">
          {[4, 6, 8, 11].map((h) => (
            <span key={h} className="w-[3px] rounded-[1px] bg-current" style={{ height: h }} />
          ))}
        </span>
        <svg viewBox="0 0 17 12" className="h-[12px] w-[17px] fill-current">
          <path d="M8.5 2.3c2.2 0 4.2.9 5.7 2.3l1-1.1A10.4 10.4 0 0 0 8.5.7 10.4 10.4 0 0 0 1.8 3.5l1 1.1a8.2 8.2 0 0 1 5.7-2.3Zm0 3.6c1.2 0 2.3.5 3.2 1.2l1.1-1.2a6.7 6.7 0 0 0-8.6 0l1.1 1.2c.9-.7 2-1.2 3.2-1.2Zm0 3.5c.5 0 1 .2 1.4.5L8.5 11.5 7.1 9.9c.4-.3.9-.5 1.4-.5Z" />
        </svg>
        <span className="flex items-center">
          <span className="flex h-[12px] w-[25px] items-center rounded-[4px] border border-current/40 p-[1.5px]">
            <span className="h-full w-[80%] rounded-[2.5px] bg-current" />
          </span>
          <span className="ml-px h-[4px] w-[1.5px] rounded-r-full bg-current/40" />
        </span>
      </span>
    </div>
  )
}

export interface IOSDeviceProps extends React.ComponentProps<"main"> {
  /** White status bar text, for photo and dark wallpaper screens */
  light?: boolean
  /** Hide the home indicator pill */
  hideHomeIndicator?: boolean
}

/** An iPhone-shaped screen. Fills the viewport on small screens. Renders the page's <main>. */
export function IOSDevice({ light, hideHomeIndicator, className, children, ...props }: IOSDeviceProps) {
  return (
    <div
      className={cn(
        "flex min-h-svh items-center justify-center bg-neutral-200 p-3 max-sm:p-0 dark:bg-neutral-900",
        palette,
        font
      )}
    >
      <div className="rounded-[58px] bg-neutral-950 p-[9px] shadow-[0_30px_80px_-20px_rgb(0_0_0/0.45)] max-sm:rounded-none max-sm:bg-transparent max-sm:p-0 max-sm:shadow-none">
        <main
          className={cn(
            "relative flex h-[min(844px,calc(100svh-42px))] w-[390px] flex-col overflow-hidden rounded-[49px] bg-(color:--ios-bg) text-(color:--ios-label) max-sm:h-svh max-sm:w-screen max-sm:rounded-none",
            className
          )}
          {...props}
        >
          <StatusBar light={light} />
          <div
            aria-hidden
            className="pointer-events-none absolute top-[11px] left-1/2 z-50 h-[34px] w-[118px] -translate-x-1/2 rounded-full bg-black max-sm:hidden"
          />
          {children}
          {!hideHomeIndicator && (
            <div
              aria-hidden
              className={cn(
                "pointer-events-none absolute bottom-2 left-1/2 z-50 h-[5px] w-[134px] -translate-x-1/2 rounded-full",
                light ? "bg-white" : "bg-(color:--ios-label)"
              )}
            />
          )}
        </main>
      </div>
    </div>
  )
}

/* ------------------------------------------------------------- navigation */

export function IOSNavButton({ className, ...props }: React.ComponentProps<"button">) {
  return (
    <button
      type="button"
      className={cn(
        "inline-flex min-h-[44px] min-w-[44px] items-center gap-0.5 text-[17px] text-(color:--ios-blue) transition-opacity active:opacity-40",
        className
      )}
      {...props}
    />
  )
}

export function IOSBackButton({ label = "Back", ...props }: React.ComponentProps<"button"> & { label?: string }) {
  return (
    <IOSNavButton className="-ml-2" {...props}>
      <ChevronLeft className="size-[26px]" strokeWidth={2.5} />
      <span className="-ml-1">{label}</span>
    </IOSNavButton>
  )
}

export interface IOSPageProps {
  title: string
  /** Show the big collapsing title under the navigation bar */
  large?: boolean
  left?: React.ReactNode
  right?: React.ReactNode
  /** Leave room for an IOSTabBar at the bottom */
  tabbed?: boolean
  /** Background of the page, defaults to the grouped background */
  className?: string
  children?: React.ReactNode
}

/** A scrolling screen with a translucent navigation bar and an optional large title */
export function IOSPage({ title, large = true, left, right, tabbed, className, children }: IOSPageProps) {
  const [scrolled, setScrolled] = React.useState(0)
  const collapsed = !large || scrolled > 42

  return (
    <div className="relative min-h-0 flex-1">
      <header
        className={cn(
          "absolute inset-x-0 top-0 z-30 flex h-[98px] items-end border-b px-4 transition-[background-color,border-color,backdrop-filter] duration-200",
          collapsed
            ? "border-(color:--ios-sep) bg-(color:--ios-bg)/80 backdrop-blur-xl backdrop-saturate-150"
            : "border-transparent bg-transparent"
        )}
      >
        <div className="relative flex h-11 w-full items-center justify-between">
          <div className="z-10 flex min-w-[44px] justify-start">{left}</div>
          <h1
            className={cn(
              "pointer-events-none absolute inset-x-0 text-center text-[17px] font-semibold transition-opacity duration-150",
              collapsed ? "opacity-100" : "opacity-0"
            )}
          >
            {title}
          </h1>
          <div className="z-10 flex min-w-[44px] justify-end gap-1">{right}</div>
        </div>
      </header>
      <div
        onScroll={(event) => setScrolled(event.currentTarget.scrollTop)}
        className={cn(
          "h-full overflow-y-auto overscroll-contain pt-[98px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
          tabbed ? "pb-[99px]" : "pb-10",
          className
        )}
      >
        {large && <h2 className="px-4 pt-1 pb-2 text-[34px] leading-[41px] font-bold tracking-[0.37px]">{title}</h2>}
        {children}
      </div>
    </div>
  )
}

export interface IOSTabItem {
  id: string
  label: string
  icon: React.ComponentType<{ className?: string; strokeWidth?: number }>
  badge?: number
}

export interface IOSTabBarProps {
  items: IOSTabItem[]
  value: string
  onValueChange: (id: string) => void
  /** Draw over a dark photographic screen */
  dark?: boolean
  className?: string
}

export function IOSTabBar({ items, value, onValueChange, dark, className }: IOSTabBarProps) {
  return (
    <nav
      aria-label="Tabs"
      className={cn(
        "absolute inset-x-0 bottom-0 z-30 flex h-[83px] items-start justify-around border-t px-2 pt-1.5 backdrop-blur-xl backdrop-saturate-150",
        dark
          ? "border-white/10 bg-black/60 text-white"
          : "border-(color:--ios-sep) bg-(color:--ios-bg)/80",
        className
      )}
    >
      {items.map(({ id, label, icon: Icon, badge }) => {
        const active = id === value
        return (
          <button
            key={id}
            type="button"
            aria-current={active ? "page" : undefined}
            onClick={() => onValueChange(id)}
            className={cn(
              "relative flex min-w-16 flex-col items-center gap-0.5 transition-colors active:opacity-50",
              active ? "text-(color:--ios-blue)" : dark ? "text-white/55" : "text-(color:--ios-label2)"
            )}
          >
            <Icon className="size-[26px]" strokeWidth={active ? 2.4 : 1.8} />
            <span className="text-[10px] font-medium">{label}</span>
            {badge ? (
              <span className="absolute -top-0.5 left-1/2 ml-1 min-w-[18px] rounded-full bg-(color:--ios-red) px-1 text-center text-[12px] leading-[18px] font-normal text-white">
                {badge}
              </span>
            ) : null}
          </button>
        )
      })}
    </nav>
  )
}

/* ------------------------------------------------------------------- lists */

export interface IOSGroupProps {
  header?: React.ReactNode
  footer?: React.ReactNode
  className?: string
  children?: React.ReactNode
}

/** Inset grouped list container. Put IOSRow (or any element with `relative`) inside. */
export function IOSGroup({ header, footer, className, children }: IOSGroupProps) {
  return (
    <section className={cn("mx-4 mb-6", className)}>
      {header && (
        <h3 className="px-4 pb-1.5 text-[13px] tracking-[-0.08px] text-(color:--ios-label2) uppercase">{header}</h3>
      )}
      <div className="overflow-hidden rounded-[16px] bg-(color:--ios-surface)">{children}</div>
      {footer && <p className="px-4 pt-1.5 text-[13px] leading-[18px] text-(color:--ios-label2)">{footer}</p>}
    </section>
  )
}

export interface IOSRowProps {
  title: React.ReactNode
  subtitle?: React.ReactNode
  /** Value shown on the right in secondary color */
  detail?: React.ReactNode
  /** An icon element, drawn white on a rounded square of `iconClassName` */
  icon?: React.ReactNode
  iconClassName?: string
  /** Show the disclosure chevron */
  chevron?: boolean
  /** Replaces the detail and chevron, for example an IOSSwitch */
  right?: React.ReactNode
  /** Leading content that replaces the icon square, for example an avatar */
  leading?: React.ReactNode
  destructive?: boolean
  onClick?: () => void
  className?: string
}

export function IOSRow({
  title,
  subtitle,
  detail,
  icon,
  iconClassName = "bg-(color:--ios-blue)",
  chevron,
  right,
  leading,
  destructive,
  onClick,
  className,
}: IOSRowProps) {
  const Tag = onClick ? "button" : "div"
  const hasLead = Boolean(icon || leading)
  return (
    <Tag
      {...(onClick ? { type: "button" as const, onClick } : {})}
      className={cn(
        "relative flex min-h-[44px] w-full items-center gap-3 px-4 py-[11px] text-left text-[17px] leading-[22px] transition-colors",
        "after:absolute after:right-0 after:bottom-0 after:h-px after:bg-(color:--ios-sep) last:after:hidden",
        hasLead ? "after:left-[60px]" : "after:left-4",
        onClick && "active:bg-(color:--ios-fill)",
        destructive && "text-(color:--ios-red)",
        className
      )}
    >
      {leading}
      {icon && (
        <span
          className={cn(
            "flex size-[29px] shrink-0 items-center justify-center rounded-[7px] text-white [&_svg]:size-[18px]",
            iconClassName
          )}
        >
          {icon}
        </span>
      )}
      <span className="min-w-0 flex-1">
        <span className="block truncate">{title}</span>
        {subtitle && <span className="block truncate text-[15px] leading-5 text-(color:--ios-label2)">{subtitle}</span>}
      </span>
      {right ?? (
        <>
          {detail && <span className="shrink-0 text-(color:--ios-label2)">{detail}</span>}
          {chevron && <ChevronRight className="-mr-1 size-5 shrink-0 text-(color:--ios-label3)" strokeWidth={2.6} />}
        </>
      )}
    </Tag>
  )
}

/* ---------------------------------------------------------------- controls */

export interface IOSSwitchProps {
  checked: boolean
  onCheckedChange: (checked: boolean) => void
  label?: string
  tint?: string
}

export function IOSSwitch({ checked, onCheckedChange, label, tint }: IOSSwitchProps) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-label={label}
      onClick={() => onCheckedChange(!checked)}
      style={checked && tint ? { backgroundColor: tint } : undefined}
      className={cn(
        "relative h-[31px] w-[51px] shrink-0 rounded-full transition-colors duration-300",
        checked ? "bg-(color:--ios-green)" : "bg-(color:--ios-fill) shadow-[inset_0_0_0_1px_var(--ios-fill)]"
      )}
    >
      <span
        className={cn(
          "absolute top-[2px] left-[2px] size-[27px] rounded-full bg-white shadow-[0_3px_8px_rgb(0_0_0/0.15),0_3px_1px_rgb(0_0_0/0.06)] transition-transform duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)]",
          checked && "translate-x-5"
        )}
      />
    </button>
  )
}

export interface IOSSegmentedProps {
  options: string[]
  value: number
  onValueChange: (index: number) => void
  className?: string
}

export function IOSSegmented({ options, value, onValueChange, className }: IOSSegmentedProps) {
  return (
    <div role="tablist" className={cn("relative flex h-8 rounded-[9px] bg-(color:--ios-fill) p-0.5", className)}>
      <span
        aria-hidden
        className="absolute top-0.5 bottom-0.5 rounded-[7px] bg-(color:--ios-thumb,#fff) shadow-[0_3px_8px_rgb(0_0_0/0.12),0_3px_1px_rgb(0_0_0/0.04)] transition-[left] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] dark:bg-(color:--ios-thumb,#636366)"
        style={{ width: `calc((100% - 4px) / ${options.length})`, left: `calc(2px + ${value} * (100% - 4px) / ${options.length})` }}
      />
      {options.map((option, index) => (
        <button
          key={option}
          type="button"
          role="tab"
          aria-selected={index === value}
          onClick={() => onValueChange(index)}
          className={cn("relative z-10 flex-1 text-[13px] font-medium", index === value && "font-semibold")}
        >
          {option}
        </button>
      ))}
    </div>
  )
}

export interface IOSButtonProps extends React.ComponentProps<"button"> {
  variant?: "filled" | "tinted" | "plain"
  /** Any CSS color, defaults to the system blue */
  tint?: string
}

export function IOSButton({ variant = "filled", tint, className, style, ...props }: IOSButtonProps) {
  const color = tint ?? "var(--ios-blue)"
  return (
    <button
      type="button"
      style={{
        ...(variant === "filled" ? (tint ? { backgroundColor: color } : {}) : variant === "tinted" ? { color, backgroundColor: `color-mix(in oklab, ${color} 15%, transparent)` } : { color }),
        ...style,
      }}
      className={cn(
        "inline-flex h-[50px] items-center justify-center gap-2 rounded-[14px] px-5 text-[17px] font-semibold transition-[transform,opacity] active:scale-[0.97] active:opacity-70 disabled:opacity-40",
        variant === "filled" && "text-white",
        variant === "filled" && !tint && "bg-[#0064d2]",
        variant === "plain" && "h-11 font-normal",
        className
      )}
      {...props}
    />
  )
}

export function IOSSearchField({ className, ...props }: React.ComponentProps<"input">) {
  return (
    <label
      className={cn(
        "flex h-9 items-center gap-1.5 rounded-[10px] bg-(color:--ios-fill) px-2 text-(color:--ios-label2)",
        className
      )}
    >
      <Search className="size-4 shrink-0" strokeWidth={2.4} />
      <input
        type="search"
        placeholder="Search"
        className="min-w-0 flex-1 bg-transparent text-[17px] text-(color:--ios-label) outline-none placeholder:text-(color:--ios-label2) [&::-webkit-search-cancel-button]:hidden"
        {...props}
      />
    </label>
  )
}

export interface IOSAppIconProps {
  icon?: React.ReactNode
  /** Tailwind background classes, for example a gradient */
  className?: string
  size?: number
  label?: string
  /** Show the label under the icon, as on the home screen */
  showLabel?: boolean
  labelClassName?: string
  onClick?: () => void
}

export function IOSAppIcon({ icon, className, size = 60, label, showLabel, labelClassName, onClick }: IOSAppIconProps) {
  const Tag = onClick ? "button" : "div"
  return (
    <Tag
      {...(onClick ? { type: "button" as const, onClick, "aria-label": label } : {})}
      className={cn("flex flex-col items-center gap-1.5 transition-transform", onClick && "active:scale-90")}
    >
      <span
        className={cn("flex items-center justify-center rounded-[22.5%] text-white shadow-sm", className)}
        style={{ width: size, height: size }}
      >
        {icon}
      </span>
      {showLabel && label && (
        <span className={cn("text-[11px] leading-none text-white [text-shadow:0_1px_3px_rgb(0_0_0/0.35)]", labelClassName)}>
          {label}
        </span>
      )}
    </Tag>
  )
}

/** A rounded surface, like a widget or an Apple Wallet card */
export function IOSCard({ className, ...props }: React.ComponentProps<"div">) {
  return <div className={cn("rounded-[22px] bg-(color:--ios-surface) p-4", className)} {...props} />
}

/* ------------------------------------------------------------------- sheet */

export interface IOSSheetProps {
  open: boolean
  onClose: () => void
  title?: string
  left?: React.ReactNode
  right?: React.ReactNode
  /** Height as a CSS length, defaults to a tall detent */
  height?: string
  children?: React.ReactNode
}

/** Modal bottom sheet, clipped to the device screen */
export function IOSSheet({ open, onClose, title, left, right, height = "80%", children }: IOSSheetProps) {
  React.useEffect(() => {
    if (!open) return
    const onKey = (event: KeyboardEvent) => event.key === "Escape" && onClose()
    window.addEventListener("keydown", onKey)
    return () => window.removeEventListener("keydown", onKey)
  }, [open, onClose])

  return (
    <div className={cn("absolute inset-0 z-40", !open && "pointer-events-none")}>
      <div
        aria-hidden
        onClick={onClose}
        className={cn("absolute inset-0 bg-black/40 transition-opacity duration-300", open ? "opacity-100" : "opacity-0")}
      />
      <div
        role="dialog"
        aria-modal="true"
        aria-label={title}
        inert={!open}
        style={{ height }}
        className={cn(
          "absolute inset-x-0 bottom-0 flex flex-col overflow-hidden rounded-t-[38px] bg-(color:--ios-bg) shadow-2xl transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
          open ? "translate-y-0" : "translate-y-full"
        )}
      >
        <div aria-hidden className="mx-auto mt-1.5 h-[5px] w-9 shrink-0 rounded-full bg-(color:--ios-label3)" />
        {(title || left || right) && (
          <div className="relative flex h-[54px] shrink-0 items-center justify-between px-5">
            <div className="z-10 min-w-12">{left}</div>
            <h2 className="absolute inset-x-0 text-center text-[17px] font-semibold">{title}</h2>
            <div className="z-10 flex min-w-12 justify-end">{right}</div>
          </div>
        )}
        <div className="min-h-0 flex-1 overflow-y-auto pb-10 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
          {children}
        </div>
      </div>
    </div>
  )
}
```
