suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Components82
  • Overview
  • Accordion
  • Alert
  • Alert Dialog
  • Alert Sheet
  • Amount Input
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Bottom Bar
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Chip
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • FAB
  • Field
  • Fullscreen Menu
  • Haptics
  • Header
  • Hover Card
  • Icon
  • Input
  • Input Accessory Bar
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Keyboard Scroll View
  • Keypad
  • Label
  • Marker
  • Menubar
  • Message
  • Message Scroller
  • Native Select
  • Navigation Menu
  • Page Indicator
  • Pagination
  • Pin Pad
  • Popover
  • Progress
  • QR Scanner
  • Questionnaire
  • Radio Group
  • Resizable
  • Screen
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature Pad
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Tab Bar
  • Table
  • Tabs
  • Text
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Wheel Picker
  • Widget

suiss/ui · Component

Widget

A rounded card in the style of home screen widgets, with fixed sizes and optional drag-to-resize.

Widget is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

import { SunIcon } from "@phosphor-icons/react"
import {
  Widget,
  WidgetContent,
  WidgetDescription,
  WidgetFooter,
  WidgetHeader,
  WidgetIcon,
  WidgetTitle,
  WidgetValue,
} from "@/components/ui/widget"

export default function WidgetDemo() {
  return (
    <Widget size="small" align="start">
      <WidgetHeader>
        <WidgetIcon>
          <SunIcon className="fill-current" />
        </WidgetIcon>
        <WidgetTitle>Istanbul</WidgetTitle>
      </WidgetHeader>
      <WidgetContent>
        <WidgetValue>24°</WidgetValue>
        <WidgetDescription>Partly cloudy</WidgetDescription>
      </WidgetContent>
      <WidgetFooter>H 27° · L 18°</WidgetFooter>
    </Widget>
  )
}

Widget is a rounded muted card that comes in four sizes: small (168 × 168), medium (352 × 168), large (352 × 352) and extra-large (720 × 352), or auto to fill its parent. Compose it from WidgetHeader, which holds a WidgetIcon, WidgetTitle and an optional WidgetAction, followed by WidgetValue, WidgetDescription, WidgetContent and a WidgetFooter pinned to the bottom.

With resizable, a corner handle lets people drag the widget to another size. While dragging, a dashed outline previews the size it will snap to on release. Use widgets for glanceable summaries on a dashboard; for general grouped content, use Card.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/widget

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add widget

Manual

  1. Add the cn helper.

  2. Copy the source into components/ui/widget.tsx:

components/ui/widget.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

type WidgetSize = "small" | "medium" | "large" | "extra-large"

const WIDGET_DIMENSIONS: Record<WidgetSize, [number, number]> = {
  small: [168, 168],
  medium: [352, 168],
  large: [352, 352],
  "extra-large": [720, 352],
}

function nearestSize(width: number, height: number, sizes: WidgetSize[]) {
  let best: WidgetSize | null = null
  1. Update the import paths to match your project.

Usage

import { Widget, WidgetHeader, WidgetIcon, WidgetTitle, WidgetAction, WidgetDescription, WidgetValue, WidgetContent, WidgetFooter } from "@/components/ui/widget"
<Widget size="small" align="start">
  <WidgetHeader>
    <WidgetIcon>
      <SunIcon weight="fill" />
    </WidgetIcon>
    <WidgetTitle>Istanbul</WidgetTitle>
  </WidgetHeader>
  <WidgetContent>
    <WidgetValue>24°</WidgetValue>
    <WidgetDescription>Partly cloudy</WidgetDescription>
  </WidgetContent>
  <WidgetFooter>H 27° · L 18°</WidgetFooter>
</Widget>

Use size with onSizeChange to control the size, or defaultSize to set where an uncontrolled widget starts. Without either, a resizable widget starts small and a fixed widget is auto. Limit the sizes a drag can snap to with sizes.

Set resizable="free" to follow the pointer without snapping; the widget stays between the smallest size and the largest allowed size. Double-click the handle to return to the starting size.

Adapt the content to the current size with the group/widget group and data-size, for example group-data-[size=small]/widget:hidden.

Examples

Sales

Shows a resizable widget whose chart and list adapt to each size.

import { CashRegisterIcon } from "@phosphor-icons/react"
import type * as React from "react"
import {
  Widget,
  WidgetDescription,
  WidgetHeader,
  WidgetIcon,
  WidgetTitle,
  WidgetValue,
} from "@/components/ui/widget"

const hourly = [12, 18, 14, 30, 44, 34, 22, 40, 100, 46, 30, 14]

const topItems = [
  { name: "Grilled meatballs", count: 38 },
  { name: "Lahmacun", count: 31 },
  { name: "Adana kebab", count: 24 },
  { name: "Künefe", count: 19 },
]

function Hint({ children }: { children: React.ReactNode }) {
  return <p className="mt-6 text-xs text-muted-foreground">{children}</p>
}

export default function WidgetSales() {
  return (
    <div>
      <Widget resizable defaultSize="large">
        <WidgetHeader className="gap-1.5 text-amber-600 dark:text-amber-400">
          <WidgetIcon size="sm" className="text-amber-600 dark:text-amber-400">
            <CashRegisterIcon />
          </WidgetIcon>
          <WidgetTitle>Hestia</WidgetTitle>
        </WidgetHeader>
        <div className="flex min-h-0 flex-col gap-3 group-data-[size=medium]/widget:flex-1 group-data-[size=medium]/widget:flex-row group-data-[size=medium]/widget:items-end">
          <div className="shrink-0">
            <WidgetValue>₺18,240</WidgetValue>
            <WidgetDescription className="text-xs">
              Today's revenue · 42 orders
            </WidgetDescription>
          </div>
          <div
            aria-hidden="true"
            className="flex h-14 shrink-0 items-end gap-1 group-data-[size=medium]/widget:h-full group-data-[size=medium]/widget:flex-1 group-data-[size=small]/widget:hidden"
          >
            {hourly.map((value, index) => (
              <span
                key={index}
                data-now={value === 100}
                className="flex-1 rounded-sm bg-secondary data-[now=true]:bg-orange-500"
                style={{ height: `${value}%` }}
              />
            ))}
          </div>
        </div>
        <ul className="flex flex-col group-data-[size=medium]/widget:hidden group-data-[size=small]/widget:hidden">
          {topItems.map((item) => (
            <li
              key={item.name}
              className="flex items-center justify-between border-t py-1.5 text-sm"
            >
              {item.name}
              <span className="text-muted-foreground tabular-nums">
                {item.count}
              </span>
            </li>
          ))}
        </ul>
      </Widget>
      <Hint>
        Hover the corner and drag the handle. Double-click it to reset.
      </Hint>
    </div>
  )
}

Kitchen

Places the resize handle inside the widget's corner.

import { CookingPotIcon } from "@phosphor-icons/react"
import type * as React from "react"
import {
  Widget,
  WidgetDescription,
  WidgetHeader,
  WidgetIcon,
  WidgetTitle,
  WidgetValue,
} from "@/components/ui/widget"

const tickets = [
  { id: "#1042", place: "Table 4", minutes: 18 },
  { id: "#1043", place: "Takeaway", minutes: 15 },
  { id: "#1044", place: "Table 12", minutes: 9 },
  { id: "#1045", place: "Pickup", minutes: 7 },
  { id: "#1046", place: "Garden 3", minutes: 5 },
  { id: "#1047", place: "Table 7", minutes: 3 },
  { id: "#1048", place: "Takeaway", minutes: 1 },
]

function Hint({ children }: { children: React.ReactNode }) {
  return <p className="mt-6 text-xs text-muted-foreground">{children}</p>
}

export default function WidgetKitchen() {
  return (
    <div>
      <Widget
        resizable
        handle="inside"
        defaultSize="large"
        className="flex-row gap-4"
      >
        <div className="flex w-32 shrink-0 flex-col gap-1 group-data-[size=small]/widget:w-full">
          <WidgetHeader className="gap-1.5 text-amber-600 dark:text-amber-400">
            <WidgetIcon
              size="sm"
              className="text-amber-600 dark:text-amber-400"
            >
              <CookingPotIcon />
            </WidgetIcon>
            <WidgetTitle>Kitchen</WidgetTitle>
          </WidgetHeader>
          <WidgetValue className="mt-2 text-5xl">7</WidgetValue>
          <WidgetDescription className="text-xs">
            pending orders
          </WidgetDescription>
          <p className="text-xs font-medium text-destructive">2 running late</p>
        </div>
        <ul className="flex min-w-0 flex-1 flex-col group-data-[size=small]/widget:hidden">
          {tickets.map((ticket) => (
            <li
              key={ticket.id}
              className="flex items-center gap-2 border-b py-1.5 text-sm group-data-[size=medium]/widget:nth-[n+4]:hidden"
            >
              <span
                aria-hidden="true"
                data-late={ticket.minutes >= 15}
                className="size-1.5 shrink-0 rounded-full bg-orange-500 data-[late=true]:bg-red-500"
              />
              <span className="min-w-0 flex-1 truncate">
                {ticket.id} · {ticket.place}
              </span>
              <span
                data-late={ticket.minutes >= 15}
                className="text-xs text-muted-foreground tabular-nums data-[late=true]:font-medium data-[late=true]:text-destructive"
              >
                {ticket.minutes} min
              </span>
            </li>
          ))}
        </ul>
      </Widget>
      <Hint>Handle inside the corner. Hover the corner to reveal it.</Hint>
    </div>
  )
}

Reservations

Limits resizing to the small and medium sizes.

import { CalendarBlankIcon } from "@phosphor-icons/react"
import type * as React from "react"
import {
  Widget,
  WidgetAction,
  WidgetHeader,
  WidgetIcon,
  WidgetTitle,
  WidgetValue,
} from "@/components/ui/widget"

function Hint({ children }: { children: React.ReactNode }) {
  return <p className="mt-6 text-xs text-muted-foreground">{children}</p>
}

export default function WidgetReservations() {
  return (
    <div>
      <Widget resizable defaultSize="medium" sizes={["small", "medium"]}>
        <WidgetHeader className="gap-1.5 text-primary">
          <WidgetIcon size="sm" className="text-primary">
            <CalendarBlankIcon />
          </WidgetIcon>
          <WidgetTitle>Reservations</WidgetTitle>
          <WidgetAction className="text-xs text-muted-foreground">
            Up next
          </WidgetAction>
        </WidgetHeader>
        <div>
          <WidgetValue>20:00</WidgetValue>
          <p className="text-sm">Ayşe Y. · 6 guests</p>
        </div>
        <div className="mt-auto flex flex-col gap-1 group-data-[size=small]/widget:hidden">
          <div className="flex justify-between text-xs text-muted-foreground tabular-nums">
            <span>18:00</span>
            <span>20:00</span>
            <span>22:00</span>
            <span>24:00</span>
          </div>
          <div className="relative h-2 rounded-full bg-secondary">
            <span className="absolute inset-y-0 start-[30%] w-[45%] rounded-full bg-blue-500" />
          </div>
        </div>
      </Widget>
      <Hint>Snaps between small and medium only.</Hint>
    </div>
  )
}

Weekly report

Starts at extra-large and reveals extra content only at that size.

import { ChartBarIcon, TriangleIcon } from "@phosphor-icons/react"
import type * as React from "react"
import {
  Widget,
  WidgetHeader,
  WidgetIcon,
  WidgetTitle,
  WidgetValue,
} from "@/components/ui/widget"

const week = [
  { day: "Mon", value: 52 },
  { day: "Tue", value: 44 },
  { day: "Wed", value: 60 },
  { day: "Thu", value: 56 },
  { day: "Fri", value: 82 },
  { day: "Sat", value: 100 },
  { day: "Sun", value: 70 },
]

const channels = [
  { name: "Dine-in", amount: "₺64,200", tint: "bg-blue-500" },
  { name: "Takeaway", amount: "₺38,900", tint: "bg-orange-500" },
  { name: "Pickup", amount: "₺18,800", tint: "bg-green-500" },
]

const bestSellers = [
  { name: "Grilled meatballs", count: 238 },
  { name: "Lahmacun", count: 201 },
  { name: "Adana kebab", count: 164 },
  { name: "Künefe", count: 131 },
  { name: "Ayran", count: 120 },
  { name: "Lentil soup", count: 96 },
] as const

function Hint({ children }: { children: React.ReactNode }) {
  return <p className="mt-6 text-xs text-muted-foreground">{children}</p>
}

export default function WidgetWeeklyReport() {
  return (
    <div>
      <Widget resizable defaultSize="extra-large" className="flex-row gap-6">
        <div className="flex min-w-0 flex-1 flex-col gap-3">
          <WidgetHeader className="gap-1.5 text-green-600 dark:text-green-400">
            <WidgetIcon
              size="sm"
              className="text-green-600 dark:text-green-400"
            >
              <ChartBarIcon />
            </WidgetIcon>
            <WidgetTitle>This week</WidgetTitle>
          </WidgetHeader>
          <div className="flex min-h-0 flex-col gap-3 group-data-[size=medium]/widget:flex-1 group-data-[size=medium]/widget:flex-row group-data-[size=medium]/widget:items-end">
            <div className="shrink-0">
              <WidgetValue>₺121,900</WidgetValue>
              <p className="flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400">
                <TriangleIcon className="size-2.5 fill-current" />
                12% more than last week
              </p>
            </div>
            <div className="flex h-24 min-w-0 shrink-0 items-end gap-2 group-data-[size=medium]/widget:h-full group-data-[size=medium]/widget:gap-1.5 group-data-[size=medium]/widget:flex-1 group-data-[size=small]/widget:hidden">
              {week.map((day) => (
                <div
                  key={day.day}
                  className="flex h-full min-w-0 flex-1 flex-col items-center justify-end gap-1"
                >
                  <div className="flex min-h-0 w-full flex-1 items-end">
                    <span
                      aria-hidden="true"
                      data-top={day.value === 100}
                      className="w-full rounded-md bg-secondary data-[top=true]:bg-green-500"
                      style={{ height: `${day.value}%` }}
                    />
                  </div>
                  <span className="text-xs text-muted-foreground group-data-[size=medium]/widget:hidden">
                    {day.day}
                  </span>
                </div>
              ))}
            </div>
          </div>
          <ul className="flex flex-col group-data-[size=medium]/widget:hidden group-data-[size=small]/widget:hidden">
            {channels.map((channel) => (
              <li
                key={channel.name}
                className="flex items-center gap-2 border-t py-1.5 text-sm"
              >
                <span
                  aria-hidden="true"
                  className={`size-2 rounded-full ${channel.tint}`}
                />
                <span className="flex-1">{channel.name}</span>
                <span className="text-muted-foreground tabular-nums">
                  {channel.amount}
                </span>
              </li>
            ))}
          </ul>
        </div>
        <div className="hidden w-72 shrink-0 flex-col gap-2 group-data-[size=extra-large]/widget:flex">
          <p className="text-xs font-semibold text-muted-foreground">
            Best sellers
          </p>
          {bestSellers.map((item) => (
            <div key={item.name} className="flex flex-col gap-1">
              <div className="flex justify-between text-sm">
                {item.name}
                <span className="text-muted-foreground tabular-nums">
                  {item.count}
                </span>
              </div>
              <div className="h-1 rounded-full bg-secondary">
                <div
                  className="h-full rounded-full bg-green-500"
                  style={{
                    width: `${(item.count / bestSellers[0].count) * 100}%`,
                  }}
                />
              </div>
            </div>
          ))}
        </div>
      </Widget>
      <Hint>Extra large shows best sellers, large hides them.</Hint>
    </div>
  )
}

Free resize

Resizes freely with the pointer instead of snapping to sizes.

import {
  BowlFoodIcon,
  CoffeeIcon,
  HamburgerIcon,
  IceCreamIcon,
  OrangeSliceIcon,
  PizzaIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Widget, WidgetTitle } from "@/components/ui/widget"

const menu = [
  {
    name: "Meatballs",
    price: "₺280",
    icon: (
      <HamburgerIcon />
    ),
  },
  {
    name: "Pide",
    price: "₺220",
    icon: (
      <PizzaIcon />
    ),
  },
  {
    name: "Soup",
    price: "₺90",
    icon: (
      <BowlFoodIcon />
    ),
  },
  {
    name: "Coffee",
    price: "₺75",
    icon: (
      <CoffeeIcon />
    ),
  },
  {
    name: "Künefe",
    price: "₺160",
    icon: (
      <IceCreamIcon />
    ),
  },
  {
    name: "Lemonade",
    price: "₺70",
    icon: (
      <OrangeSliceIcon />
    ),
  },
  {
    name: "Ayran",
    price: "₺40",
    icon: (
      <CoffeeIcon />
    ),
  },
  {
    name: "Baklava",
    price: "₺180",
    icon: (
      <IceCreamIcon />
    ),
  },
]

function Hint({ children }: { children: React.ReactNode }) {
  return <p className="mt-6 text-xs text-muted-foreground">{children}</p>
}

function FreeResizeExample() {
  const ref = React.useRef<HTMLDivElement>(null)
  const [box, setBox] = React.useState({ width: 0, height: 0, columns: 0 })

  React.useEffect(() => {
    const widget = ref.current
    if (!widget) return
    const measure = () => {
      const grid = widget.querySelector<HTMLElement>("[data-menu-grid]")
      const columns = grid
        ? getComputedStyle(grid).gridTemplateColumns.split(" ").length
        : 0
      setBox({
        width: Math.round(widget.offsetWidth),
        height: Math.round(widget.offsetHeight),
        columns,
      })
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(widget)
    return () => observer.disconnect()
  }, [])

  return (
    <div>
      <Widget
        ref={ref}
        resizable="free"
        defaultSize="large"
        className="gap-0 p-0"
      >
        <div className="flex items-baseline justify-between px-4 pt-4 pb-3">
          <WidgetTitle className="text-xl">Hestia POS</WidgetTitle>
          <span className="text-sm text-muted-foreground tabular-nums">
            {box.columns} columns
          </span>
        </div>
        <ScrollArea className="min-h-0 flex-1">
          <div
            data-menu-grid
            className="grid grid-cols-2 gap-2 px-4 pb-4 @[30rem]/widget:grid-cols-3 @[44rem]/widget:grid-cols-4"
          >
            {menu.map((item) => (
              <div
                key={item.name}
                className="flex flex-col gap-2 rounded-2xl bg-card p-3"
              >
                <span
                  aria-hidden="true"
                  className="text-amber-600 dark:text-amber-400 [&_svg]:size-6"
                >
                  {item.icon}
                </span>
                <div>
                  <p className="text-sm font-semibold">{item.name}</p>
                  <p className="text-sm text-muted-foreground tabular-nums">
                    {item.price}
                  </p>
                </div>
              </div>
            ))}
          </div>
        </ScrollArea>
      </Widget>
      <Hint>
        Free resize · {box.width} × {box.height}
      </Hint>
    </div>
  )
}

export default function WidgetFreeResize() {
  return <FreeResizeExample />
}

Auto resize

Fills its parent with auto, and snaps to fixed sizes when dragged.

import { CashRegisterIcon } from "@phosphor-icons/react"
import {
  Widget,
  WidgetDescription,
  WidgetHeader,
  WidgetIcon,
  WidgetTitle,
} from "@/components/ui/widget"

export default function WidgetAutoResize() {
  return (
    <div className="h-60 w-full max-w-180">
      <Widget resizable defaultSize="auto">
        <WidgetHeader>
          <WidgetIcon>
            <CashRegisterIcon />
          </WidgetIcon>
          <WidgetTitle>Point of sale</WidgetTitle>
        </WidgetHeader>
        <WidgetDescription>
          Fills its parent. Drag to snap to a size, double-click to fill again.
        </WidgetDescription>
      </Widget>
    </div>
  )
}

Centered

Centers the icon, title and description with align="center".

import { CashRegisterIcon } from "@phosphor-icons/react"
import {
  Widget,
  WidgetDescription,
  WidgetHeader,
  WidgetIcon,
  WidgetTitle,
} from "@/components/ui/widget"

export default function WidgetCentered() {
  return (
    <Widget size="small" align="center">
      <WidgetHeader>
        <WidgetIcon size="lg">
          <CashRegisterIcon />
        </WidgetIcon>
        <WidgetTitle>Point of sale</WidgetTitle>
      </WidgetHeader>
      <WidgetDescription>3 open orders</WidgetDescription>
    </Widget>
  )
}

Sizes

Shows each fixed size side by side.

import {
  Widget,
  WidgetFooter,
  WidgetHeader,
  WidgetTitle,
} from "@/components/ui/widget"

const gridSpans = {
  small: "1 × 1",
  medium: "2 × 1",
  large: "2 × 2",
  "extra-large": "4 × 2",
}

export default function WidgetSizes() {
  return (
    <div className="flex flex-wrap items-start gap-4">
      {(["small", "medium", "large", "extra-large"] as const).map((size) => (
        <Widget key={size} size={size}>
          <WidgetHeader>
            <WidgetTitle className="capitalize">
              {size.replace("-", " ")}
            </WidgetTitle>
          </WidgetHeader>
          <WidgetFooter>{gridSpans[size]}</WidgetFooter>
        </Widget>
      ))}
    </div>
  )
}

Dashboard

Arranges fixed widgets in a grid as a dashboard.

import {
  CalendarBlankIcon,
  CashRegisterIcon,
  CookingPotIcon,
  SunIcon,
} from "@phosphor-icons/react"
import {
  Widget,
  WidgetContent,
  WidgetDescription,
  WidgetFooter,
  WidgetHeader,
  WidgetIcon,
  WidgetTitle,
  WidgetValue,
} from "@/components/ui/widget"

const tickets = [
  { id: "#1042", place: "Table 4", minutes: 18 },
  { id: "#1043", place: "Takeaway", minutes: 15 },
  { id: "#1044", place: "Table 12", minutes: 9 },
  { id: "#1045", place: "Pickup", minutes: 7 },
  { id: "#1046", place: "Garden 3", minutes: 5 },
  { id: "#1047", place: "Table 7", minutes: 3 },
  { id: "#1048", place: "Takeaway", minutes: 1 },
]

export default function WidgetDashboard() {
  return (
    <div className="grid max-w-4xl auto-rows-[10.5rem] grid-cols-2 gap-4 md:grid-cols-4">
      <Widget>
        <WidgetHeader>
          <WidgetIcon>
            <SunIcon className="fill-current" />
          </WidgetIcon>
          <WidgetTitle>Istanbul</WidgetTitle>
        </WidgetHeader>
        <WidgetValue>24°</WidgetValue>
        <WidgetFooter>Partly cloudy</WidgetFooter>
      </Widget>
      <Widget align="center">
        <WidgetHeader>
          <WidgetIcon size="lg">
            <CookingPotIcon />
          </WidgetIcon>
          <WidgetTitle>Kitchen display</WidgetTitle>
        </WidgetHeader>
        <WidgetDescription>5 tickets</WidgetDescription>
      </Widget>
      <Widget className="col-span-2">
        <WidgetHeader className="gap-1.5 text-amber-600 dark:text-amber-400">
          <WidgetIcon size="sm" className="text-amber-600 dark:text-amber-400">
            <CashRegisterIcon />
          </WidgetIcon>
          <WidgetTitle>Hestia</WidgetTitle>
        </WidgetHeader>
        <WidgetValue>₺18,240</WidgetValue>
        <WidgetFooter>Today's revenue · 42 orders</WidgetFooter>
      </Widget>
      <Widget className="col-span-2 row-span-2">
        <WidgetHeader className="gap-1.5 text-primary">
          <WidgetIcon size="sm" className="text-primary">
            <CalendarBlankIcon />
          </WidgetIcon>
          <WidgetTitle>Reservations</WidgetTitle>
        </WidgetHeader>
        <WidgetContent className="gap-2">
          {tickets.slice(0, 5).map((ticket, index) => (
            <div key={ticket.id} className="flex items-baseline gap-3 text-sm">
              <span className="w-11 shrink-0 text-muted-foreground tabular-nums">
                {`${18 + index}:00`}
              </span>
              <span className="truncate">{ticket.place}</span>
            </div>
          ))}
        </WidgetContent>
      </Widget>
      <Widget className="col-span-2 row-span-2" align="center">
        <WidgetHeader>
          <WidgetIcon size="lg">
            <CashRegisterIcon />
          </WidgetIcon>
          <WidgetTitle>Point of sale</WidgetTitle>
        </WidgetHeader>
        <WidgetDescription>3 open orders</WidgetDescription>
      </Widget>
    </div>
  )
}

API Reference

Anatomy11 parts
<Widget>
<WidgetHeader>
<WidgetIcon />
<WidgetTitle />
<WidgetAction />
</WidgetHeader>
<WidgetContent>
<WidgetValue />
<WidgetDescription />
</WidgetContent>
<WidgetFooter />
<WidgetValue />
<WidgetDescription />
</Widget>
01

Widget

The rounded card that sets the size, alignment and resize behavior.

PropTypeDefaultDescription
sizeWidgetSize | "auto"-Controls the current size, or auto to fill the parent.
defaultSizeWidgetSize | "auto"-Sets the starting size when uncontrolled; double-clicking the handle also returns to it.
onSizeChange(size: WidgetSize | "auto") => void-Called with the new size when a drag snaps to a different size or the size is reset.
sizesWidgetSize[]["small", "medium", "large", "extra-large"]Lists the sizes a drag can snap to; the largest also caps free resizing.
resizableboolean | "free"falseAdds a resize handle; true snaps to sizes, and free follows the pointer.
handle"inside" | "outside""outside"Places the resize handle inside or outside the bottom corner.
align"start" | "center""start"Centers the content horizontally and vertically when set to center.

Also accepts the props of React.ComponentProps<"div">.

02

WidgetHeader

A row that holds the icon, title and action.

Also accepts the props of React.ComponentProps<"div">.

03

WidgetIcon

A decorative icon, shown on a tile in the default and lg sizes.

PropTypeDefaultDescription
size"default" | "sm" | "lg""default"Sets the icon size: sm without a tile, or default and lg on a rounded tile.

Also accepts the props of React.ComponentProps<"span">.

04

WidgetTitle

The widget's title, truncated to one line.

Also accepts the props of React.ComponentProps<"p">.

05

WidgetAction

A slot at the end of the header for a control; hidden when centered.

Also accepts the props of React.ComponentProps<"div">.

06

WidgetDescription

Secondary text that explains the content.

Also accepts the props of React.ComponentProps<"p">.

07

WidgetValue

A large figure in tabular numbers, such as a total or temperature.

Also accepts the props of React.ComponentProps<"p">.

08

WidgetContent

A flexible column that fills the remaining space.

Also accepts the props of React.ComponentProps<"div">.

09

WidgetFooter

Small secondary text pinned to the bottom of the widget.

Also accepts the props of React.ComponentProps<"div">.

Accessibility

  • WidgetIcon is hidden from assistive technologies, so make sure the title conveys its meaning.
  • The resize handle is hidden from assistive technologies and works with a pointer only. Offer another way to change the size, such as a control that sets size, if it matters.

Notes

  • Widget sets data-size and data-align, and data-resizing while dragging. It's also a @container/widget container for container queries.
  • Parts carry data-slot attributes such as widget, widget-container, widget-handle, widget-preview and widget-header.
  • The handle is visible on hover and always visible on touch screens. It mirrors in right-to-left layouts.
  • Size changes animate over 500ms, and the animation is removed when reduced motion is preferred.
iOS
Widget on iOS in light modeWidget on iOS in dark mode
import { SunIcon } from "phosphor-react-native"
import { Icon } from "@/components/ui/icon"
import { Widget, WidgetContent, WidgetDescription, WidgetFooter, WidgetHeader, WidgetIcon, WidgetTitle, WidgetValue } from "@/components/ui/widget"

function Weather() {
  return (
    <>
      <WidgetHeader>
        <WidgetIcon>
          <Icon as={SunIcon} weight="fill" />
        </WidgetIcon>
        <WidgetTitle>Istanbul</WidgetTitle>
      </WidgetHeader>
      <WidgetContent>
        <WidgetValue>24°</WidgetValue>
        <WidgetDescription>Partly cloudy</WidgetDescription>
      </WidgetContent>
      <WidgetFooter>H 27° · L 18°</WidgetFooter>
    </>
  )
}

export function WidgetExample() {
  return (
    <Widget size="small" align="start">
      <Weather />
    </Widget>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct WidgetExample: View {
  var body: some View {
    Widget(.small) {
      WidgetWeather()
    }
  }
}

struct WidgetWeather: View {
  var body: some View {
    WidgetHeader {
      WidgetIcon {
        Ph.sun.fill
      }
      WidgetTitle("Istanbul")
    }
    WidgetContent {
      WidgetValue(verbatim: "24°")
      WidgetDescription("Partly cloudy")
    }
    WidgetFooter(verbatim: "H 27° · L 18°")
  }
}
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.style.TextOverflow
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.fill.Sun
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.Widget
import com.esuiss.ui.components.WidgetContent
import com.esuiss.ui.components.WidgetDescription
import com.esuiss.ui.components.WidgetFooter
import com.esuiss.ui.components.WidgetHeader
import com.esuiss.ui.components.WidgetIcon
import com.esuiss.ui.components.WidgetSize
import com.esuiss.ui.components.WidgetTitle
import com.esuiss.ui.components.WidgetValue

@Composable
fun WidgetExample() {
    Widget(size = WidgetSize.Small) { WidgetWeather() }
}

@Composable
fun WidgetFooterText(text: String) {
    WidgetFooter {
        Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
    }
}

@Composable
fun WidgetWeather() {
    WidgetHeader {
        WidgetIcon { Icon(PhosphorIcons.Fill.Sun, contentDescription = null) }
        WidgetTitle("Istanbul")
    }
    WidgetContent {
        WidgetValue("24°")
        WidgetDescription("Partly cloudy")
    }
    WidgetFooterText("H 27° · L 18°")
}

Widget is a View with a rounded, filled surface. It comes in four fixed sizes, small (168×168), medium (352×168), large (352×352) and extra-large (720×352), or auto, which fills its parent. Fixed sizes animate with Reanimated when the size changes.

With resizable, a corner handle lets the user drag the widget between sizes: a dashed outline previews the size it will snap to, a selection haptic plays on each new snap (when a haptics adapter is provided through HapticsContext), and double-tapping the handle returns to the initial size. Only sizes that fit the available width are offered. Set align="center" to center the content, which also hides WidgetAction.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/widget

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add widget
$ pnpm dlx @esuiss/ui@latest add widget --platform swift
$ pnpm dlx @esuiss/ui@latest add widget --platform kotlin

The CLI also adds what it builds on: Icon, Text. On React Native it also adds the animated view components/ui/animated-view.tsx.

Manual

  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/haptics.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

uniwind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/ui/widget.tsx:
uniwind/components/ui/widget.tsx
import { cn } from "cn"
import type { IconProps as PhosphorIconProps } from "phosphor-react-native"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  I18nManager,
  View,
  type ViewProps,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  Easing,
  ReduceMotion,
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/haptics.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

nativewind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/ui/widget.tsx:
nativewind/components/ui/widget.tsx
import { cn } from "cn"
import type { IconProps as PhosphorIconProps } from "phosphor-react-native"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  I18nManager,
  View,
  type ViewProps,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  Easing,
  ReduceMotion,
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/haptics.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

stylesheet/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/ui/widget.tsx:
stylesheet/components/ui/widget.tsx
import type { IconProps as PhosphorIconProps } from "phosphor-react-native"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  I18nManager,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  View,
  type ViewProps,
  type ViewStyle,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  Easing,
  ReduceMotion,
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. Copy the theme files every view uses:
swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
swift/Components/Widget.swift
import PhosphorSwift
import SwiftUI

public enum WidgetSize: CaseIterable, Sendable {
  case small, medium, large, extraLarge

  public var dimensions: CGSize {
    switch self {
    case .small: CGSize(width: 168, height: 168)
    case .medium: CGSize(width: 352, height: 168)
    case .large: CGSize(width: 352, height: 352)
    case .extraLarge: CGSize(width: 720, height: 352)
    }
  }
}
swift/Components/Button.swift
import SwiftUI

public struct VariantButtonStyle: ButtonStyle {
  public enum Variant: CaseIterable, Sendable {
    case `default`, outline, secondary, tinted, ghost, plain, destructive, link
  }

  public enum Size: CaseIterable, Sendable {
    case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl

    var height: CGFloat {
      switch self {
      case .xs, .iconXs: 24
      case .sm, .iconSm: 32
      case .default, .icon: 36
      case .lg, .iconLg: 44
swift/Components/ButtonGroup.swift
import SwiftUI

extension EnvironmentValues {
  @Entry var buttonGroup: Axis? = nil
  @Entry var buttonGroupSize: CGSize = .zero
}

enum ButtonGroupSpace: Hashable {
  case group
}

struct ButtonGroupFrame {
  let bounds: Anchor<CGRect>
  let outlined: Bool
}
swift/Components/Dialog.swift
import SwiftUI

extension View {
  public func dialog<Content: View>(
    isPresented: Binding<Bool>,
    @ViewBuilder content: @escaping () -> Content
  ) -> some View {
    modifier(
      CoverPresenter(isPresented: isPresented) { visible in
        CenteredStage(visible: visible, dismissible: true, content: content)
      }
    )
  }
}

struct CenteredStage<Content: View>: View {
swift/Components/Drawer.swift
import SwiftUI

extension View {
  public func drawer<Content: View>(
    isPresented: Binding<Bool>,
    detents: Set<PresentationDetent>? = nil,
    selection: Binding<PresentationDetent>? = nil,
    onDismiss: (() -> Void)? = nil,
    @ViewBuilder content: @escaping () -> Content
  ) -> some View {
    let binding = isPresented
    return sheet(isPresented: isPresented, onDismiss: onDismiss) {
      DrawerHost(detents: detents, selection: selection, content: content)
        .environment(\.overlayDismiss, OverlayDismiss { binding.wrappedValue = false })
    }
  }
swift/Components/Haptics.swift
import SwiftUI

extension EnvironmentValues {
  @Entry public var hapticsEnabled = true
}

extension View {
  public func haptics(_ enabled: Bool) -> some View {
    environment(\.hapticsEnabled, enabled)
  }

  func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
    modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
  }

  func hapticFeedback<Trigger: Equatable>(
swift/Components/Overlay.swift
import PhosphorSwift
import Observation
import SwiftUI

public enum PopupAlignment: Sendable, CaseIterable {
  case start, center, end
}

struct OverlayDismiss: Sendable {
  let action: @MainActor @Sendable () -> Void

  @MainActor func callAsFunction() {
    action()
  }
}
swift/Components/Popover.swift
import SwiftUI
import UIKit

public struct PopoverContent<Content: View>: View {
  let width: CGFloat?
  let content: Content

  public init(width: CGFloat? = 288, @ViewBuilder content: () -> Content) {
    self.width = width
    self.content = content()
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      content
    }
swift/Components/TextVariant.swift
import SwiftUI

public enum TextVariant: CaseIterable, Sendable {
  case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
swift/Patterns/Adaptive.swift
import SwiftUI

extension EnvironmentValues {
  @Entry var patternCompact: Bool? = nil
}

@propertyWrapper
struct CompactLayout: DynamicProperty {
  @Environment(\.patternCompact) private var forced
  @Environment(\.horizontalSizeClass) private var sizeClass

  init() {}

  var wrappedValue: Bool {
    forced ?? (sizeClass != .regular)
  }
swift/Patterns/Preview.swift
import SwiftUI

extension View {
  public func preview<Content: View>(
    isPresented: Binding<Bool>,
    arrowEdge: Edge = .top,
    @ViewBuilder content: @escaping () -> Content
  ) -> some View {
    modifier(PreviewPresenter(isPresented: isPresented, arrowEdge: arrowEdge, content: content))
  }
}

struct PreviewPresenter<Body: View>: ViewModifier {
  let isPresented: Binding<Bool>
  let arrowEdge: Edge
  @ViewBuilder let content: () -> Body
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/components/Widget.kt
package com.esuiss.ui.components

import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/FormSupport.kt
package com.esuiss.ui.components

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
kotlin/components/Haptics.kt
package com.esuiss.ui.components

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

val LocalHapticsEnabled = staticCompositionLocalOf { true }

@Composable
fun ProvideHaptics(enabled: Boolean, content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalHapticsEnabled provides enabled, content = content)
}
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { Widget, WidgetHeader, WidgetIcon, WidgetTitle, WidgetAction, WidgetDescription, WidgetValue, WidgetContent, WidgetFooter } from "@/components/ui/widget"
<Widget size="small" align="start">
  <Weather />
</Widget>

Compose the content from WidgetHeader (with WidgetIcon, WidgetTitle and WidgetAction), then WidgetValue, WidgetDescription or WidgetContent, and WidgetFooter, which sticks to the bottom. Plain strings passed to WidgetContent and WidgetFooter are wrapped in Text.

The size is uncontrolled with defaultSize, or controlled with size and onSizeChange. Use sizes to limit which sizes the user can pick. A non-resizable widget defaults to auto; a resizable one defaults to small.

useWidget returns the current size and align so custom content can adapt to the widget size.

Examples

Sizes

The four fixed sizes side by side.

iOS
Widget (sizes) on iOS in light modeWidget (sizes) on iOS in dark mode
import { ScrollView } from "react-native"
import { Widget, WidgetFooter, WidgetHeader, type WidgetSize, WidgetTitle } from "@/components/ui/widget"

const gridSpans: Record<WidgetSize, string> = {
  small: "1 × 1",
  medium: "2 × 1",
  large: "2 × 2",
  "extra-large": "4 × 2",
}

export function WidgetSizesExample() {
  return (
    <ScrollView
      horizontal
      contentContainerClassName="items-start gap-4"
      showsHorizontalScrollIndicator={false}
    >
      {(["small", "medium", "large", "extra-large"] as const).map((size) => (
        <Widget key={size} size={size}>
          <WidgetHeader>
            <WidgetTitle className="capitalize">
              {size.replace("-", " ")}
            </WidgetTitle>
          </WidgetHeader>
          <WidgetFooter>{gridSpans[size]}</WidgetFooter>
        </Widget>
      ))}
    </ScrollView>
  )
}
import EsuissUI
import SwiftUI

struct WidgetSizesExample: View {
  var body: some View {
    ScrollView(.horizontal, showsIndicators: false) {
      HStack(alignment: .top, spacing: 16) {
        ForEach(widgetSizeNames, id: \.title) { entry in
          Widget(entry.size) {
            WidgetHeader {
              WidgetTitle(verbatim: entry.title)
            }
            WidgetFooter(verbatim: entry.span)
          }
        }
      }
    }
  }
}

struct WidgetSizeName {
  let size: WidgetSize
  let title: String
  let span: String
}

let widgetSizeNames = [
  WidgetSizeName(size: .small, title: "Small", span: "1 × 1"),
  WidgetSizeName(size: .medium, title: "Medium", span: "2 × 1"),
  WidgetSizeName(size: .large, title: "Large", span: "2 × 2"),
  WidgetSizeName(size: .extraLarge, title: "Extra large", span: "4 × 2"),
]
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.Widget
import com.esuiss.ui.components.WidgetFooter
import com.esuiss.ui.components.WidgetHeader
import com.esuiss.ui.components.WidgetSize
import com.esuiss.ui.components.WidgetTitle

fun widgetSizeName(size: WidgetSize): String = when (size) {
    WidgetSize.Auto -> "auto"
    WidgetSize.Small -> "small"
    WidgetSize.Medium -> "medium"
    WidgetSize.Large -> "large"
    WidgetSize.ExtraLarge -> "extra-large"
}

fun widgetGridSpan(size: WidgetSize): String = when (size) {
    WidgetSize.Small -> "1 × 1"
    WidgetSize.Medium -> "2 × 1"
    WidgetSize.Large -> "2 × 2"
    else -> "4 × 2"
}

@Composable
fun WidgetFooterText(text: String) {
    WidgetFooter {
        Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
    }
}

@Composable
fun WidgetSizes() {
    Row(
        Modifier.horizontalScroll(rememberScrollState()),
        horizontalArrangement = Arrangement.spacedBy(16.dp),
        verticalAlignment = Alignment.Top,
    ) {
        listOf(WidgetSize.Small, WidgetSize.Medium, WidgetSize.Large, WidgetSize.ExtraLarge).forEach { size ->
            Widget(size = size) {
                WidgetHeader {
                    WidgetTitle(
                        widgetSizeName(size).split("-").joinToString(" ") { word -> word.replaceFirstChar { it.uppercaseChar() } },
                    )
                }
                WidgetFooterText(widgetGridSpan(size))
            }
        }
    }
}

Editing

A resizable widget starting at large.

iOS
Widget (editing) on iOS in light modeWidget (editing) on iOS in dark mode
import { CashRegisterIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { useWidget, Widget, WidgetContent, WidgetDescription, WidgetFooter, WidgetHeader, WidgetIcon, WidgetTitle, WidgetValue } from "@/components/ui/widget"

const hourly = [12, 18, 14, 30, 44, 34, 22, 40, 100, 46, 30, 14].map(
  (value, hour) => ({ hour: hour + 10, value })
)

const topItems = [
  { name: "Grilled meatballs", count: 38 },
  { name: "Lahmacun", count: 31 },
  { name: "Adana kebab", count: 24 },
]

function Hint({ children }: { children: React.ReactNode }) {
  return <Text className="mt-6 text-xs text-muted-foreground">{children}</Text>
}

function SalesBody() {
  const { size } = useWidget()
  return (
    <>
      <WidgetHeader className="gap-1.5">
        <WidgetIcon
          size="sm"
          iconClassName="text-amber-600 dark:text-amber-400"
        >
          <Icon as={CashRegisterIcon} weight="fill" />
        </WidgetIcon>
        <WidgetTitle className="text-amber-600 dark:text-amber-400">
          Hestia
        </WidgetTitle>
      </WidgetHeader>
      <View
        className={
          size === "medium"
            ? "min-h-0 grow flex-row items-end gap-3"
            : "min-h-0 gap-3"
        }
      >
        <WidgetContent className="grow-0">
          <WidgetValue>₺18,240</WidgetValue>
          <WidgetDescription>Today's revenue</WidgetDescription>
        </WidgetContent>
        {size === "small" ? null : (
          <View
            className={
              size === "medium"
                ? "h-full flex-1 flex-row items-end gap-1"
                : "h-14 flex-row items-end gap-1"
            }
          >
            {hourly.map(({ hour, value }) => (
              <View
                key={hour}
                className={
                  value === 100
                    ? "flex-1 rounded-sm bg-amber-500"
                    : "flex-1 rounded-sm bg-amber-500/40"
                }
                style={{ height: `${value}%` }}
              />
            ))}
          </View>
        )}
      </View>
      {size === "large" || size === "extra-large" ? (
        <View className="min-h-0 shrink overflow-hidden">
          {topItems.map((item) => (
            <View
              key={item.name}
              className="flex-row items-center justify-between border-b border-border py-1.5"
            >
              <Text className="text-sm">{item.name}</Text>
              <Text className="text-sm text-muted-foreground">
                {item.count}
              </Text>
            </View>
          ))}
        </View>
      ) : null}
      <WidgetFooter>42 orders · 18:42</WidgetFooter>
    </>
  )
}

export function WidgetEditingExample() {
  return (
    <View className="w-full">
      <Widget resizable defaultSize="large">
        <SalesBody />
      </Widget>
      <Hint>
        Drag the corner handle to snap between sizes. Double tap it to reset.
      </Hint>
    </View>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct WidgetEditingExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      WidgetEditable(initial: .large) {
        WidgetSales()
      }
      WidgetHint(text: "Drag the corner handle to snap between sizes. Double tap it to reset.")
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

let widgetHourly: [CGFloat] = [12, 18, 14, 30, 44, 34, 22, 40, 100, 46, 30, 14]

struct WidgetTopItem {
  let name: String
  let count: Int
}

let widgetTopItems = [
  WidgetTopItem(name: "Grilled meatballs", count: 38),
  WidgetTopItem(name: "Lahmacun", count: 31),
  WidgetTopItem(name: "Adana kebab", count: 24),
]

struct WidgetHint: View {
  let text: String

  var body: some View {
    Text(verbatim: text)
      .textStyle(.xs)
      .foregroundStyle(EsuissColor.mutedForeground)
      .padding(.top, 24)
  }
}

struct WidgetEditable<Content: View>: View {
  let sizes: [WidgetSize]
  let handle: WidgetHandlePlacement
  let resetSize: WidgetSize
  @ViewBuilder let content: () -> Content

  @State private var size: WidgetSize

  init(
    initial: WidgetSize,
    sizes: [WidgetSize] = WidgetSize.allCases,
    handle: WidgetHandlePlacement = .outside,
    @ViewBuilder content: @escaping () -> Content
  ) {
    self.sizes = sizes
    self.handle = handle
    resetSize = initial
    self.content = content
    _size = State(initialValue: initial)
  }

  var body: some View {
    Widget(size: $size, sizes: sizes, editing: true, handle: handle, resetSize: resetSize) {
      content()
    }
  }
}

struct WidgetSalesHeader: View {
  var body: some View {
    WidgetHeader {
      WidgetIcon(size: .sm) {
        Ph.cashRegister.fill.foregroundStyle(Color.adaptive(light: Palette.amber600, dark: Palette.amber400))
      }
      WidgetTitle("Hestia")
        .foregroundStyle(Color.adaptive(light: Palette.amber600, dark: Palette.amber400))
    }
  }
}

struct WidgetBars: View {
  var body: some View {
    GeometryReader { proxy in
      HStack(alignment: .bottom, spacing: 4) {
        ForEach(Array(widgetHourly.enumerated()), id: \.offset) { _, value in
          RoundedRectangle(cornerRadius: Radius.sm, style: .continuous)
            .fill(value == 100 ? Palette.amber500 : Palette.amber500.opacity(0.4))
            .frame(height: proxy.size.height * value / 100)
            .frame(maxWidth: .infinity)
        }
      }
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
    }
    .accessibilityHidden(true)
  }
}

struct WidgetSales: View {
  @Environment(\.widgetSize) private var size

  var body: some View {
    WidgetSalesHeader()
    if size == .medium {
      HStack(alignment: .bottom, spacing: 12) {
        revenue
        WidgetBars()
      }
      .frame(maxHeight: .infinity)
    } else {
      VStack(alignment: .leading, spacing: 12) {
        revenue
        if size != .small {
          WidgetBars().frame(height: 56)
        }
      }
    }
    if size == .large || size == .extraLarge {
      VStack(spacing: 0) {
        ForEach(widgetTopItems, id: \.name) { item in
          HStack {
            Text(verbatim: item.name)
              .textStyle(.sm)
            Spacer(minLength: 8)
            Text(verbatim: "\(item.count)")
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
          }
          .padding(.vertical, 6)
          .overlay(alignment: .bottom) {
            EsuissColor.border.frame(height: 1)
          }
        }
      }
      .clipped()
    }
    WidgetFooter(verbatim: "42 orders · 18:42")
  }

  var revenue: some View {
    WidgetContent {
      WidgetValue(verbatim: "₺18,240")
      WidgetDescription("Today's revenue")
    }
    .fixedSize(horizontal: true, vertical: false)
  }
}
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.fill.CashRegister
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Separator
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.Widget
import com.esuiss.ui.components.WidgetContent
import com.esuiss.ui.components.WidgetDescription
import com.esuiss.ui.components.WidgetFooter
import com.esuiss.ui.components.WidgetHeader
import com.esuiss.ui.components.WidgetIcon
import com.esuiss.ui.components.WidgetIconSize
import com.esuiss.ui.components.WidgetSize
import com.esuiss.ui.components.WidgetTitle
import com.esuiss.ui.components.WidgetValue
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

val widgetHourly = listOf(12, 18, 14, 30, 44, 34, 22, 40, 100, 46, 30, 14)

val widgetTopItems = listOf(
    "Grilled meatballs" to 38,
    "Lahmacun" to 31,
    "Adana kebab" to 24,
)

@Composable
fun WidgetTintedTitle(text: String, color: Color) {
    CompositionLocalProvider(LocalContentStyle provides LocalContentStyle.current.copy(color = color)) {
        WidgetTitle(text)
    }
}

@Composable
fun WidgetFooterText(text: String) {
    WidgetFooter {
        Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
    }
}

@Composable
fun WidgetHint(text: String) {
    Text(
        text,
        Modifier.padding(top = 24.dp),
        style = Typography.xs,
        color = EsuissTheme.colors.mutedForeground,
    )
}

@Composable
fun WidgetSalesHeader() {
    val warning = if (EsuissTheme.colors.isDark) Palette.amber400 else Palette.amber600
    WidgetHeader {
        WidgetIcon(size = WidgetIconSize.Sm) { Icon(PhosphorIcons.Fill.CashRegister, contentDescription = null, tint = warning) }
        WidgetTintedTitle("Hestia", warning)
    }
}

@Composable
fun WidgetRevenue() {
    Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
        WidgetValue("₺18,240")
        WidgetDescription("Today's revenue")
    }
}

@Composable
fun WidgetBars(modifier: Modifier) {
    val warning = if (EsuissTheme.colors.isDark) Palette.amber400 else Palette.amber600
    Row(modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom) {
        widgetHourly.forEach { value ->
            Box(
                Modifier
                    .weight(1f)
                    .fillMaxHeight(value / 100f)
                    .background(if (value == 100) warning else warning.copy(alpha = 0.4f), RoundedCornerShape(Radius.sm)),
            )
        }
    }
}

@Composable
fun WidgetSalesBody(size: WidgetSize) {
    WidgetSalesHeader()
    if (size == WidgetSize.Medium) {
        WidgetContent {
            Row(
                Modifier.fillMaxWidth().fillMaxHeight(),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.Bottom,
            ) {
                WidgetRevenue()
                WidgetBars(Modifier.weight(1f).fillMaxHeight())
            }
        }
    } else {
        WidgetContent {
            Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                WidgetRevenue()
                if (size != WidgetSize.Small) WidgetBars(Modifier.fillMaxWidth().height(56.dp))
            }
        }
    }
    if (size == WidgetSize.Large || size == WidgetSize.ExtraLarge) {
        Column {
            widgetTopItems.forEach { (name, count) ->
                Row(
                    Modifier.fillMaxWidth().padding(vertical = 6.dp),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Text(name, style = Typography.sm)
                    Text(count.toString(), style = Typography.sm, color = EsuissTheme.colors.mutedForeground)
                }
                Separator()
            }
        }
    }
    WidgetFooterText("42 orders · 18:42")
}

@Composable
fun WidgetEditing() {
    Column(Modifier.fillMaxWidth()) {
        Widget(resizable = true, defaultSize = WidgetSize.Large) { WidgetSalesBody(size) }
        WidgetHint("Drag the corner handle to snap between sizes. Double tap it to reset.")
    }
}

Inside handle

A resizable widget with the handle drawn inside the corner.

iOS
Widget (inside handle) on iOS in light modeWidget (inside handle) on iOS in dark mode
import { CashRegisterIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { useWidget, Widget, WidgetContent, WidgetDescription, WidgetFooter, WidgetHeader, WidgetIcon, WidgetTitle, WidgetValue } from "@/components/ui/widget"

const hourly = [12, 18, 14, 30, 44, 34, 22, 40, 100, 46, 30, 14].map(
  (value, hour) => ({ hour: hour + 10, value })
)

const topItems = [
  { name: "Grilled meatballs", count: 38 },
  { name: "Lahmacun", count: 31 },
  { name: "Adana kebab", count: 24 },
]

function Hint({ children }: { children: React.ReactNode }) {
  return <Text className="mt-6 text-xs text-muted-foreground">{children}</Text>
}

function SalesBody() {
  const { size } = useWidget()
  return (
    <>
      <WidgetHeader className="gap-1.5">
        <WidgetIcon
          size="sm"
          iconClassName="text-amber-600 dark:text-amber-400"
        >
          <Icon as={CashRegisterIcon} weight="fill" />
        </WidgetIcon>
        <WidgetTitle className="text-amber-600 dark:text-amber-400">
          Hestia
        </WidgetTitle>
      </WidgetHeader>
      <View
        className={
          size === "medium"
            ? "min-h-0 grow flex-row items-end gap-3"
            : "min-h-0 gap-3"
        }
      >
        <WidgetContent className="grow-0">
          <WidgetValue>₺18,240</WidgetValue>
          <WidgetDescription>Today's revenue</WidgetDescription>
        </WidgetContent>
        {size === "small" ? null : (
          <View
            className={
              size === "medium"
                ? "h-full flex-1 flex-row items-end gap-1"
                : "h-14 flex-row items-end gap-1"
            }
          >
            {hourly.map(({ hour, value }) => (
              <View
                key={hour}
                className={
                  value === 100
                    ? "flex-1 rounded-sm bg-amber-500"
                    : "flex-1 rounded-sm bg-amber-500/40"
                }
                style={{ height: `${value}%` }}
              />
            ))}
          </View>
        )}
      </View>
      {size === "large" || size === "extra-large" ? (
        <View className="min-h-0 shrink overflow-hidden">
          {topItems.map((item) => (
            <View
              key={item.name}
              className="flex-row items-center justify-between border-b border-border py-1.5"
            >
              <Text className="text-sm">{item.name}</Text>
              <Text className="text-sm text-muted-foreground">
                {item.count}
              </Text>
            </View>
          ))}
        </View>
      ) : null}
      <WidgetFooter>42 orders · 18:42</WidgetFooter>
    </>
  )
}

export function WidgetInsideHandleExample() {
  return (
    <View className="w-full">
      <Widget resizable handle="inside" defaultSize="medium">
        <SalesBody />
      </Widget>
      <Hint>The handle sits inside the corner.</Hint>
    </View>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct WidgetInsideHandleExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      WidgetEditable(initial: .medium, handle: .inside) {
        WidgetSales()
      }
      WidgetHint(text: "The handle sits inside the corner.")
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

let widgetHourly: [CGFloat] = [12, 18, 14, 30, 44, 34, 22, 40, 100, 46, 30, 14]

struct WidgetTopItem {
  let name: String
  let count: Int
}

let widgetTopItems = [
  WidgetTopItem(name: "Grilled meatballs", count: 38),
  WidgetTopItem(name: "Lahmacun", count: 31),
  WidgetTopItem(name: "Adana kebab", count: 24),
]

struct WidgetHint: View {
  let text: String

  var body: some View {
    Text(verbatim: text)
      .textStyle(.xs)
      .foregroundStyle(EsuissColor.mutedForeground)
      .padding(.top, 24)
  }
}

struct WidgetEditable<Content: View>: View {
  let sizes: [WidgetSize]
  let handle: WidgetHandlePlacement
  let resetSize: WidgetSize
  @ViewBuilder let content: () -> Content

  @State private var size: WidgetSize

  init(
    initial: WidgetSize,
    sizes: [WidgetSize] = WidgetSize.allCases,
    handle: WidgetHandlePlacement = .outside,
    @ViewBuilder content: @escaping () -> Content
  ) {
    self.sizes = sizes
    self.handle = handle
    resetSize = initial
    self.content = content
    _size = State(initialValue: initial)
  }

  var body: some View {
    Widget(size: $size, sizes: sizes, editing: true, handle: handle, resetSize: resetSize) {
      content()
    }
  }
}

struct WidgetSalesHeader: View {
  var body: some View {
    WidgetHeader {
      WidgetIcon(size: .sm) {
        Ph.cashRegister.fill.foregroundStyle(Color.adaptive(light: Palette.amber600, dark: Palette.amber400))
      }
      WidgetTitle("Hestia")
        .foregroundStyle(Color.adaptive(light: Palette.amber600, dark: Palette.amber400))
    }
  }
}

struct WidgetBars: View {
  var body: some View {
    GeometryReader { proxy in
      HStack(alignment: .bottom, spacing: 4) {
        ForEach(Array(widgetHourly.enumerated()), id: \.offset) { _, value in
          RoundedRectangle(cornerRadius: Radius.sm, style: .continuous)
            .fill(value == 100 ? Palette.amber500 : Palette.amber500.opacity(0.4))
            .frame(height: proxy.size.height * value / 100)
            .frame(maxWidth: .infinity)
        }
      }
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
    }
    .accessibilityHidden(true)
  }
}

struct WidgetSales: View {
  @Environment(\.widgetSize) private var size

  var body: some View {
    WidgetSalesHeader()
    if size == .medium {
      HStack(alignment: .bottom, spacing: 12) {
        revenue
        WidgetBars()
      }
      .frame(maxHeight: .infinity)
    } else {
      VStack(alignment: .leading, spacing: 12) {
        revenue
        if size != .small {
          WidgetBars().frame(height: 56)
        }
      }
    }
    if size == .large || size == .extraLarge {
      VStack(spacing: 0) {
        ForEach(widgetTopItems, id: \.name) { item in
          HStack {
            Text(verbatim: item.name)
              .textStyle(.sm)
            Spacer(minLength: 8)
            Text(verbatim: "\(item.count)")
              .textStyle(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
          }
          .padding(.vertical, 6)
          .overlay(alignment: .bottom) {
            EsuissColor.border.frame(height: 1)
          }
        }
      }
      .clipped()
    }
    WidgetFooter(verbatim: "42 orders · 18:42")
  }

  var revenue: some View {
    WidgetContent {
      WidgetValue(verbatim: "₺18,240")
      WidgetDescription("Today's revenue")
    }
    .fixedSize(horizontal: true, vertical: false)
  }
}
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.fill.CashRegister
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Separator
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.Widget
import com.esuiss.ui.components.WidgetContent
import com.esuiss.ui.components.WidgetDescription
import com.esuiss.ui.components.WidgetFooter
import com.esuiss.ui.components.WidgetHandlePlacement
import com.esuiss.ui.components.WidgetHeader
import com.esuiss.ui.components.WidgetIcon
import com.esuiss.ui.components.WidgetIconSize
import com.esuiss.ui.components.WidgetSize
import com.esuiss.ui.components.WidgetTitle
import com.esuiss.ui.components.WidgetValue
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

val widgetHourly = listOf(12, 18, 14, 30, 44, 34, 22, 40, 100, 46, 30, 14)

val widgetTopItems = listOf(
    "Grilled meatballs" to 38,
    "Lahmacun" to 31,
    "Adana kebab" to 24,
)

@Composable
fun WidgetTintedTitle(text: String, color: Color) {
    CompositionLocalProvider(LocalContentStyle provides LocalContentStyle.current.copy(color = color)) {
        WidgetTitle(text)
    }
}

@Composable
fun WidgetFooterText(text: String) {
    WidgetFooter {
        Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
    }
}

@Composable
fun WidgetHint(text: String) {
    Text(
        text,
        Modifier.padding(top = 24.dp),
        style = Typography.xs,
        color = EsuissTheme.colors.mutedForeground,
    )
}

@Composable
fun WidgetSalesHeader() {
    val warning = if (EsuissTheme.colors.isDark) Palette.amber400 else Palette.amber600
    WidgetHeader {
        WidgetIcon(size = WidgetIconSize.Sm) { Icon(PhosphorIcons.Fill.CashRegister, contentDescription = null, tint = warning) }
        WidgetTintedTitle("Hestia", warning)
    }
}

@Composable
fun WidgetRevenue() {
    Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
        WidgetValue("₺18,240")
        WidgetDescription("Today's revenue")
    }
}

@Composable
fun WidgetBars(modifier: Modifier) {
    val warning = if (EsuissTheme.colors.isDark) Palette.amber400 else Palette.amber600
    Row(modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom) {
        widgetHourly.forEach { value ->
            Box(
                Modifier
                    .weight(1f)
                    .fillMaxHeight(value / 100f)
                    .background(if (value == 100) warning else warning.copy(alpha = 0.4f), RoundedCornerShape(Radius.sm)),
            )
        }
    }
}

@Composable
fun WidgetSalesBody(size: WidgetSize) {
    WidgetSalesHeader()
    if (size == WidgetSize.Medium) {
        WidgetContent {
            Row(
                Modifier.fillMaxWidth().fillMaxHeight(),
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.Bottom,
            ) {
                WidgetRevenue()
                WidgetBars(Modifier.weight(1f).fillMaxHeight())
            }
        }
    } else {
        WidgetContent {
            Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
                WidgetRevenue()
                if (size != WidgetSize.Small) WidgetBars(Modifier.fillMaxWidth().height(56.dp))
            }
        }
    }
    if (size == WidgetSize.Large || size == WidgetSize.ExtraLarge) {
        Column {
            widgetTopItems.forEach { (name, count) ->
                Row(
                    Modifier.fillMaxWidth().padding(vertical = 6.dp),
                    horizontalArrangement = Arrangement.SpaceBetween,
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Text(name, style = Typography.sm)
                    Text(count.toString(), style = Typography.sm, color = EsuissTheme.colors.mutedForeground)
                }
                Separator()
            }
        }
    }
    WidgetFooterText("42 orders · 18:42")
}

@Composable
fun WidgetInsideHandle() {
    Column(Modifier.fillMaxWidth()) {
        Widget(resizable = true, handle = WidgetHandlePlacement.Inside, defaultSize = WidgetSize.Medium) { WidgetSalesBody(size) }
        WidgetHint("The handle sits inside the corner.")
    }
}

Limited sizes

Resizing limited to small and medium.

iOS
Widget (limited sizes) on iOS in light modeWidget (limited sizes) on iOS in dark mode
import { CalendarBlankIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { useWidget, Widget, WidgetContent, WidgetDescription, WidgetHeader, WidgetIcon, WidgetTitle, WidgetValue } from "@/components/ui/widget"

const reservations = [
  { time: "18:00", place: "Table 4" },
  { time: "19:00", place: "Garden 3" },
  { time: "20:00", place: "Table 12" },
  { time: "21:00", place: "Table 7" },
]

function Hint({ children }: { children: React.ReactNode }) {
  return <Text className="mt-6 text-xs text-muted-foreground">{children}</Text>
}

function ReservationsBody() {
  const { size } = useWidget()
  return (
    <>
      <WidgetHeader className="gap-1.5">
        <WidgetIcon size="sm" iconClassName="text-primary">
          <Icon as={CalendarBlankIcon} weight="fill" />
        </WidgetIcon>
        <WidgetTitle className="text-primary">Reservations</WidgetTitle>
      </WidgetHeader>
      <View className="min-h-0 grow flex-row items-end gap-4">
        <WidgetContent className="grow-0">
          <WidgetValue>12</WidgetValue>
          <WidgetDescription>Tonight</WidgetDescription>
        </WidgetContent>
        {size === "medium" ? (
          <View className="flex-1 gap-1">
            {reservations.slice(0, 3).map((item) => (
              <View key={item.time} className="flex-row gap-3">
                <Text className="w-11 text-sm text-muted-foreground">
                  {item.time}
                </Text>
                <Text className="flex-1 text-sm" numberOfLines={1}>
                  {item.place}
                </Text>
              </View>
            ))}
          </View>
        ) : null}
      </View>
    </>
  )
}

export function WidgetLimitedSizesExample() {
  return (
    <View className="w-full">
      <Widget resizable defaultSize="medium" sizes={["small", "medium"]}>
        <ReservationsBody />
      </Widget>
      <Hint>Only small and medium are offered.</Hint>
    </View>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct WidgetLimitedSizesExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      WidgetEditable(initial: .medium, sizes: [.small, .medium]) {
        WidgetReservations()
      }
      WidgetHint(text: "Only small and medium are offered.")
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct WidgetReservation {
  let time: String
  let place: String
}

let widgetReservations = [
  WidgetReservation(time: "18:00", place: "Table 4"),
  WidgetReservation(time: "19:00", place: "Garden 3"),
  WidgetReservation(time: "20:00", place: "Table 12"),
  WidgetReservation(time: "21:00", place: "Table 7"),
]

struct WidgetHint: View {
  let text: String

  var body: some View {
    Text(verbatim: text)
      .textStyle(.xs)
      .foregroundStyle(EsuissColor.mutedForeground)
      .padding(.top, 24)
  }
}

struct WidgetEditable<Content: View>: View {
  let sizes: [WidgetSize]
  let handle: WidgetHandlePlacement
  let resetSize: WidgetSize
  @ViewBuilder let content: () -> Content

  @State private var size: WidgetSize

  init(
    initial: WidgetSize,
    sizes: [WidgetSize] = WidgetSize.allCases,
    handle: WidgetHandlePlacement = .outside,
    @ViewBuilder content: @escaping () -> Content
  ) {
    self.sizes = sizes
    self.handle = handle
    resetSize = initial
    self.content = content
    _size = State(initialValue: initial)
  }

  var body: some View {
    Widget(size: $size, sizes: sizes, editing: true, handle: handle, resetSize: resetSize) {
      content()
    }
  }
}

struct WidgetReservations: View {
  @Environment(\.widgetSize) private var size

  var body: some View {
    WidgetHeader {
      WidgetIcon(size: .sm) {
        Ph.calendarBlank.fill.foregroundStyle(EsuissColor.primary)
      }
      WidgetTitle("Reservations")
        .foregroundStyle(EsuissColor.primary)
    }
    HStack(alignment: .bottom, spacing: 16) {
      WidgetContent {
        WidgetValue(verbatim: "12")
        WidgetDescription("Tonight")
      }
      .fixedSize(horizontal: true, vertical: false)
      if size == .medium {
        VStack(alignment: .leading, spacing: 4) {
          ForEach(widgetReservations.prefix(3), id: \.time) { item in
            HStack(spacing: 12) {
              Text(verbatim: item.time)
                .textStyle(.sm)
                .foregroundStyle(EsuissColor.mutedForeground)
                .frame(width: 44, alignment: .leading)
              Text(verbatim: item.place)
                .textStyle(.sm)
                .lineLimit(1)
                .frame(maxWidth: .infinity, alignment: .leading)
            }
          }
        }
        .frame(maxWidth: .infinity)
      }
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.fill.CalendarBlank
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.Widget
import com.esuiss.ui.components.WidgetContent
import com.esuiss.ui.components.WidgetDescription
import com.esuiss.ui.components.WidgetHeader
import com.esuiss.ui.components.WidgetIcon
import com.esuiss.ui.components.WidgetIconSize
import com.esuiss.ui.components.WidgetSize
import com.esuiss.ui.components.WidgetTitle
import com.esuiss.ui.components.WidgetValue
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

val widgetReservations = listOf(
    "18:00" to "Table 4",
    "19:00" to "Garden 3",
    "20:00" to "Table 12",
    "21:00" to "Table 7",
)

@Composable
fun WidgetTintedTitle(text: String, color: Color) {
    CompositionLocalProvider(LocalContentStyle provides LocalContentStyle.current.copy(color = color)) {
        WidgetTitle(text)
    }
}

@Composable
fun WidgetHint(text: String) {
    Text(
        text,
        Modifier.padding(top = 24.dp),
        style = Typography.xs,
        color = EsuissTheme.colors.mutedForeground,
    )
}

@Composable
fun WidgetReservationsBody(size: WidgetSize) {
    val colors = EsuissTheme.colors
    WidgetHeader {
        WidgetIcon(size = WidgetIconSize.Sm) { Icon(PhosphorIcons.Fill.CalendarBlank, contentDescription = null, tint = colors.primary) }
        WidgetTintedTitle("Reservations", colors.primary)
    }
    WidgetContent {
        Row(
            Modifier.fillMaxWidth().fillMaxHeight(),
            horizontalArrangement = Arrangement.spacedBy(16.dp),
            verticalAlignment = Alignment.Bottom,
        ) {
            Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
                WidgetValue("12")
                WidgetDescription("Tonight")
            }
            if (size == WidgetSize.Medium) {
                Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
                    widgetReservations.take(3).forEach { (time, place) ->
                        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                            Text(time, Modifier.width(44.dp), style = Typography.sm, color = colors.mutedForeground)
                            Text(place, Modifier.weight(1f), style = Typography.sm, maxLines = 1, overflow = TextOverflow.Ellipsis)
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun WidgetLimitedSizes() {
    Column(Modifier.fillMaxWidth()) {
        Widget(
            resizable = true,
            defaultSize = WidgetSize.Medium,
            sizes = listOf(WidgetSize.Small, WidgetSize.Medium),
        ) { WidgetReservationsBody(size) }
        WidgetHint("Only small and medium are offered.")
    }
}

Controlled

Controls the size with size and onSizeChange.

iOS
Widget (controlled) on iOS in light modeWidget (controlled) on iOS in dark mode
import { SunIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { Widget, WidgetContent, WidgetDescription, WidgetFooter, WidgetHeader, WidgetIcon, type WidgetSize, WidgetTitle, WidgetValue } from "@/components/ui/widget"

function Hint({ children }: { children: React.ReactNode }) {
  return <Text className="mt-6 text-xs text-muted-foreground">{children}</Text>
}

function Weather() {
  return (
    <>
      <WidgetHeader>
        <WidgetIcon>
          <Icon as={SunIcon} weight="fill" />
        </WidgetIcon>
        <WidgetTitle>Istanbul</WidgetTitle>
      </WidgetHeader>
      <WidgetContent>
        <WidgetValue>24°</WidgetValue>
        <WidgetDescription>Partly cloudy</WidgetDescription>
      </WidgetContent>
      <WidgetFooter>H 27° · L 18°</WidgetFooter>
    </>
  )
}

function ControlledEditing() {
  const [size, setSize] = React.useState<WidgetSize | "auto">("small")
  return (
    <View className="w-full">
      <Widget resizable size={size} onSizeChange={setSize}>
        <Weather />
      </Widget>
      <Hint>{`Size: ${size}`}</Hint>
    </View>
  )
}

export function WidgetControlledExample() {
  return (
    <ControlledEditing />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

func widgetSizeValue(_ size: WidgetSize) -> String {
  switch size {
  case .small: "small"
  case .medium: "medium"
  case .large: "large"
  case .extraLarge: "extra-large"
  }
}

struct WidgetHint: View {
  let text: String

  var body: some View {
    Text(verbatim: text)
      .textStyle(.xs)
      .foregroundStyle(EsuissColor.mutedForeground)
      .padding(.top, 24)
  }
}

struct WidgetControlled: View {
  @State private var size: WidgetSize = .small

  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      Widget(size: $size, editing: true) {
        WidgetWeather()
      }
      WidgetHint(text: "Size: \(widgetSizeValue(size))")
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}

struct WidgetWeather: View {
  var body: some View {
    WidgetHeader {
      WidgetIcon {
        Ph.sun.fill
      }
      WidgetTitle("Istanbul")
    }
    WidgetContent {
      WidgetValue(verbatim: "24°")
      WidgetDescription("Partly cloudy")
    }
    WidgetFooter(verbatim: "H 27° · L 18°")
  }
}
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.fill.Sun
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.Widget
import com.esuiss.ui.components.WidgetContent
import com.esuiss.ui.components.WidgetDescription
import com.esuiss.ui.components.WidgetFooter
import com.esuiss.ui.components.WidgetHeader
import com.esuiss.ui.components.WidgetIcon
import com.esuiss.ui.components.WidgetSize
import com.esuiss.ui.components.WidgetTitle
import com.esuiss.ui.components.WidgetValue
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

fun widgetSizeName(size: WidgetSize): String = when (size) {
    WidgetSize.Auto -> "auto"
    WidgetSize.Small -> "small"
    WidgetSize.Medium -> "medium"
    WidgetSize.Large -> "large"
    WidgetSize.ExtraLarge -> "extra-large"
}

@Composable
fun WidgetFooterText(text: String) {
    WidgetFooter {
        Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
    }
}

@Composable
fun WidgetHint(text: String) {
    Text(
        text,
        Modifier.padding(top = 24.dp),
        style = Typography.xs,
        color = EsuissTheme.colors.mutedForeground,
    )
}

@Composable
fun WidgetWeather() {
    WidgetHeader {
        WidgetIcon { Icon(PhosphorIcons.Fill.Sun, contentDescription = null) }
        WidgetTitle("Istanbul")
    }
    WidgetContent {
        WidgetValue("24°")
        WidgetDescription("Partly cloudy")
    }
    WidgetFooterText("H 27° · L 18°")
}

@Composable
fun WidgetControlled() {
    var size by rememberSaveable { mutableStateOf(WidgetSize.Small) }
    Column(Modifier.fillMaxWidth()) {
        Widget(size = size, onSizeChange = { size = it }, resizable = true) { WidgetWeather() }
        WidgetHint("Size: ${widgetSizeName(size)}")
    }
}

Centered

Centered content with align="center".

iOS
Widget (centered) on iOS in light modeWidget (centered) on iOS in dark mode
import { CashRegisterIcon } from "phosphor-react-native"
import { Icon } from "@/components/ui/icon"
import { Widget, WidgetDescription, WidgetHeader, WidgetIcon, WidgetTitle } from "@/components/ui/widget"

export function WidgetCenteredExample() {
  return (
    <Widget size="small" align="center">
      <WidgetHeader>
        <WidgetIcon size="lg">
          <Icon as={CashRegisterIcon} />
        </WidgetIcon>
        <WidgetTitle>Point of sale</WidgetTitle>
      </WidgetHeader>
      <WidgetDescription>3 open orders</WidgetDescription>
    </Widget>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct WidgetCenteredExample: View {
  var body: some View {
    Widget(.small, align: .center) {
      WidgetHeader {
        WidgetIcon(size: .lg) {
          Ph.cashRegister.regular
        }
        WidgetTitle("Point of sale")
      }
      WidgetDescription("3 open orders")
    }
  }
}
import androidx.compose.runtime.Composable
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Widget
import com.esuiss.ui.components.WidgetAlign
import com.esuiss.ui.components.WidgetDescription
import com.esuiss.ui.components.WidgetHeader
import com.esuiss.ui.components.WidgetIcon
import com.esuiss.ui.components.WidgetIconSize
import com.esuiss.ui.components.WidgetSize
import com.esuiss.ui.components.WidgetTitle

@Composable
fun WidgetCentered() {
    Widget(size = WidgetSize.Small, align = WidgetAlign.Center) {
        WidgetHeader {
            WidgetIcon(size = WidgetIconSize.Lg) { Icon(PhosphorIcons.Regular.CashRegisterRegular, contentDescription = null) }
            WidgetTitle("Point of sale")
        }
        WidgetDescription("3 open orders")
    }
}

Auto

An auto widget that fills its parent.

iOS
Widget (auto) on iOS in light modeWidget (auto) on iOS in dark mode
import { SunIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Icon } from "@/components/ui/icon"
import { Widget, WidgetContent, WidgetDescription, WidgetFooter, WidgetHeader, WidgetIcon, WidgetTitle, WidgetValue } from "@/components/ui/widget"

function Weather() {
  return (
    <>
      <WidgetHeader>
        <WidgetIcon>
          <Icon as={SunIcon} weight="fill" />
        </WidgetIcon>
        <WidgetTitle>Istanbul</WidgetTitle>
      </WidgetHeader>
      <WidgetContent>
        <WidgetValue>24°</WidgetValue>
        <WidgetDescription>Partly cloudy</WidgetDescription>
      </WidgetContent>
      <WidgetFooter>H 27° · L 18°</WidgetFooter>
    </>
  )
}

export function WidgetAutoExample() {
  return (
    <View className="h-48 w-full">
      <Widget>
        <Weather />
      </Widget>
    </View>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct WidgetAutoExample: View {
  var body: some View {
    Widget(nil) {
      WidgetWeather()
    }
    .frame(maxWidth: .infinity)
    .frame(height: 192)
  }
}

struct WidgetWeather: View {
  var body: some View {
    WidgetHeader {
      WidgetIcon {
        Ph.sun.fill
      }
      WidgetTitle("Istanbul")
    }
    WidgetContent {
      WidgetValue(verbatim: "24°")
      WidgetDescription("Partly cloudy")
    }
    WidgetFooter(verbatim: "H 27° · L 18°")
  }
}
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.fill.Sun
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.Widget
import com.esuiss.ui.components.WidgetContent
import com.esuiss.ui.components.WidgetDescription
import com.esuiss.ui.components.WidgetFooter
import com.esuiss.ui.components.WidgetHeader
import com.esuiss.ui.components.WidgetIcon
import com.esuiss.ui.components.WidgetTitle
import com.esuiss.ui.components.WidgetValue

@Composable
fun WidgetAutoExample() {
    Widget(Modifier.height(192.dp)) { WidgetWeather() }
}

@Composable
fun WidgetFooterText(text: String) {
    WidgetFooter {
        Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
    }
}

@Composable
fun WidgetWeather() {
    WidgetHeader {
        WidgetIcon { Icon(PhosphorIcons.Fill.Sun, contentDescription = null) }
        WidgetTitle("Istanbul")
    }
    WidgetContent {
        WidgetValue("24°")
        WidgetDescription("Partly cloudy")
    }
    WidgetFooterText("H 27° · L 18°")
}

Dashboard

Several auto-sized widgets arranged in a grid.

iOS
Widget (dashboard) on iOS in light modeWidget (dashboard) on iOS in dark mode
import { CashRegisterIcon, CookingPotIcon, SunIcon } from "phosphor-react-native"
import { ScrollView, View } from "react-native"
import { Icon } from "@/components/ui/icon"
import { Widget, WidgetDescription, WidgetFooter, WidgetHeader, WidgetIcon, WidgetTitle, WidgetValue } from "@/components/ui/widget"

export function WidgetDashboardExample() {
  return (
    <ScrollView contentContainerClassName="gap-4">
      <View className="h-42 flex-row gap-4">
        <View className="flex-1">
          <Widget>
            <WidgetHeader>
              <WidgetIcon>
                <Icon as={SunIcon} weight="fill" />
              </WidgetIcon>
              <WidgetTitle>Istanbul</WidgetTitle>
            </WidgetHeader>
            <WidgetValue>24°</WidgetValue>
            <WidgetFooter>Partly cloudy</WidgetFooter>
          </Widget>
        </View>
        <View className="flex-1">
          <Widget align="center">
            <WidgetHeader>
              <WidgetIcon size="lg">
                <Icon as={CookingPotIcon} />
              </WidgetIcon>
              <WidgetTitle>Kitchen display</WidgetTitle>
            </WidgetHeader>
            <WidgetDescription>5 tickets</WidgetDescription>
          </Widget>
        </View>
      </View>
      <View className="h-42">
        <Widget>
          <WidgetHeader className="gap-1.5">
            <WidgetIcon
              size="sm"
              iconClassName="text-amber-600 dark:text-amber-400"
            >
              <Icon as={CashRegisterIcon} weight="fill" />
            </WidgetIcon>
            <WidgetTitle className="text-amber-600 dark:text-amber-400">
              Hestia
            </WidgetTitle>
          </WidgetHeader>
          <WidgetValue>₺18,240</WidgetValue>
          <WidgetFooter>Today's revenue · 42 orders</WidgetFooter>
        </Widget>
      </View>
    </ScrollView>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct WidgetDashboardExample: View {
  var body: some View {
    VStack(spacing: 16) {
      HStack(spacing: 16) {
        Widget(nil) {
          WidgetHeader {
            WidgetIcon {
              Ph.sun.fill
            }
            WidgetTitle("Istanbul")
          }
          WidgetValue(verbatim: "24°")
          WidgetFooter("Partly cloudy")
        }
        Widget(nil, align: .center) {
          WidgetHeader {
            WidgetIcon(size: .lg) {
              Ph.cookingPot.regular
            }
            WidgetTitle("Kitchen display")
          }
          WidgetDescription("5 tickets")
        }
      }
      .frame(height: 168)
      Widget(nil) {
        WidgetSalesHeader()
        WidgetValue(verbatim: "₺18,240")
        WidgetFooter("Today's revenue · 42 orders")
      }
      .frame(height: 168)
    }
    .frame(maxWidth: .infinity)
  }
}

struct WidgetSalesHeader: View {
  var body: some View {
    WidgetHeader {
      WidgetIcon(size: .sm) {
        Ph.cashRegister.fill.foregroundStyle(Color.adaptive(light: Palette.amber600, dark: Palette.amber400))
      }
      WidgetTitle("Hestia")
        .foregroundStyle(Color.adaptive(light: Palette.amber600, dark: Palette.amber400))
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.fill.CashRegister
import com.adamglin.phosphoricons.fill.Sun
import com.adamglin.phosphoricons.regular.CookingPot
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.Widget
import com.esuiss.ui.components.WidgetAlign
import com.esuiss.ui.components.WidgetDescription
import com.esuiss.ui.components.WidgetFooter
import com.esuiss.ui.components.WidgetHeader
import com.esuiss.ui.components.WidgetIcon
import com.esuiss.ui.components.WidgetIconSize
import com.esuiss.ui.components.WidgetTitle
import com.esuiss.ui.components.WidgetValue
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette

@Composable
fun WidgetTintedTitle(text: String, color: Color) {
    CompositionLocalProvider(LocalContentStyle provides LocalContentStyle.current.copy(color = color)) {
        WidgetTitle(text)
    }
}

@Composable
fun WidgetFooterText(text: String) {
    WidgetFooter {
        Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
    }
}

@Composable
fun WidgetDashboard() {
    val warning = if (EsuissTheme.colors.isDark) Palette.amber400 else Palette.amber600
    val tall = Modifier.height(168.dp)
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
            Box(Modifier.weight(1f)) {
                Widget(tall) {
                    WidgetHeader {
                        WidgetIcon { Icon(PhosphorIcons.Fill.Sun, contentDescription = null) }
                        WidgetTitle("Istanbul")
                    }
                    WidgetValue("24°")
                    WidgetFooterText("Partly cloudy")
                }
            }
            Box(Modifier.weight(1f)) {
                Widget(tall, align = WidgetAlign.Center) {
                    WidgetHeader {
                        WidgetIcon(size = WidgetIconSize.Lg) { Icon(PhosphorIcons.Regular.CookingPot, contentDescription = null) }
                        WidgetTitle("Kitchen display")
                    }
                    WidgetDescription("5 tickets")
                }
            }
        }
        Widget(tall) {
            WidgetHeader {
                WidgetIcon(size = WidgetIconSize.Sm) { Icon(PhosphorIcons.Fill.CashRegister, contentDescription = null, tint = warning) }
                WidgetTintedTitle("Hestia", warning)
            }
            WidgetValue("₺18,240")
            WidgetFooterText("Today's revenue · 42 orders")
        }
    }
}

API Reference

01

Widget

The card surface and optional resize handle.

PropTypeDefaultDescription
classNamestring-
sizeWidgetSize | "auto"-Controlled size: small, medium, large, extra-large or auto.
defaultSizeWidgetSize | "auto"-Initial size when uncontrolled.
onSizeChange(size: WidgetSize | "auto") => void-Called with the new size after a resize.
sizesWidgetSize[]ALL_SIZESSizes the user can resize to. Defaults to all four.
resizablebooleanfalseShows a drag handle for resizing.
handleWidgetHandlePlacement"outside"Handle placement: outside the corner or inside it. Defaults to outside.
alignWidgetAlign"start"start or center content alignment.
resizeLabelstring"Resize widget"Accessibility label for the handle. Defaults to "Resize widget".

Also accepts the props of ViewProps, React.RefAttributes<View>.

02

WidgetHeader

Row for the icon, title and action.

PropTypeDefaultDescription
classNamestring-

Also accepts the props of ViewProps, React.RefAttributes<View>.

03

WidgetIcon

Icon frame in default, sm or lg size.

PropTypeDefaultDescription
classNamestring-
iconClassNamestring-Classes applied to the icon inside.
size"default" | "sm" | "lg""default"default, sm or lg.

Also accepts the props of ViewProps, React.RefAttributes<View>.

04

WidgetTitle

Single-line heading.

Also accepts the props of React.ComponentProps<typeof Text>, React.RefAttributes<typeof Text>.

05

WidgetAction

Trailing slot in the header; hidden when centered.

PropTypeDefaultDescription
classNamestring-

Also accepts the props of ViewProps, React.RefAttributes<View>.

06

WidgetDescription

Secondary text.

Also accepts the props of React.ComponentProps<typeof Text>, React.RefAttributes<typeof Text>.

07

WidgetValue

Large number with tabular figures.

Also accepts the props of React.ComponentProps<typeof Text>, React.RefAttributes<typeof Text>.

08

WidgetContent

Flexible body area that clips overflow.

PropTypeDefaultDescription
classNamestring-

Also accepts the props of ViewProps, React.RefAttributes<View>.

09

WidgetFooter

Small secondary text and icons pinned to the bottom.

PropTypeDefaultDescription
classNamestring-

Also accepts the props of ViewProps, React.RefAttributes<View>.

Accessibility

  • The resize handle has the adjustable role, its resizeLabel and the current size as its value; increment and decrement actions step through the available sizes, so VoiceOver and TalkBack users can resize without dragging.
  • WidgetTitle has the heading role.
  • WidgetIcon and the resize preview outline are hidden from assistive technology.
  • Size animations respect the system reduce motion setting.

Notes

  • Uses bg-muted for the surface, bg-secondary for icon frames, and text-muted-foreground/50 for the handle, which turns text-primary while dragging.
  • The handle and drag direction mirror in right-to-left layouts.
  • Light and dark colors follow the system appearance.
NextPatternsPreviousWheel Picker

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Sales
    • Kitchen
    • Reservations
    • Weekly report
    • Free resize
    • Auto resize
    • Centered
    • Sizes
    • Dashboard
  4. 04API Reference
    • Widget
    • WidgetHeader
    • WidgetIcon
    • WidgetTitle
    • WidgetAction
    • WidgetDescription
    • WidgetValue
    • WidgetContent
    • WidgetFooter
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Sizes
    • Editing
    • Inside handle
    • Limited sizes
    • Controlled
    • Centered
    • Auto
    • Dashboard
  10. 10API Reference
    • Widget
    • WidgetHeader
    • WidgetIcon
    • WidgetTitle
    • WidgetAction
    • WidgetDescription
    • WidgetValue
    • WidgetContent
    • WidgetFooter
  11. 11Accessibility
  12. 12Notes