# Bar Chart Interactive

A screen-time report with stacked category bars, a week and day toggle, selectable days and a most-used apps list.

Bar Chart Interactive is an open-source chart 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.

Source: https://ui.suiss.app/docs/charts/bar-chart-interactive

**React**

**Example**

```tsx title="components/charts/bar-chart-interactive.tsx"
"use client"

import * as React from "react"
import {
  Bar,
  BarChart,
  CartesianGrid,
  Cell,
  ReferenceLine,
  useActiveTooltipDataPoints,
  XAxis,
  YAxis,
} from "recharts"

import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Category = "social" | "productivity" | "entertainment"

const CATEGORIES: Category[] = ["social", "productivity", "entertainment"]

const DAYS = [
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
  "Sunday",
]

const USAGE: [social: number, productivity: number, entertainment: number][] = [
  [48, 95, 62],
  [55, 120, 40],
  [70, 88, 95],
  [42, 130, 35],
  [65, 100, 80],
  [110, 30, 140],
  [95, 25, 120],
]

const WEEK: Record<Category, number>[] = USAGE.map(
  ([social, productivity, entertainment]) => ({
    social,
    productivity,
    entertainment,
  })
)

const PROFILE = [
  1, 0, 0, 0, 0, 0, 1, 4, 6, 5, 4, 5, 7, 6, 5, 4, 5, 6, 7, 9, 10, 11, 8, 4,
]

const APPS: { name: string; category: Category; share: number }[] = [
  { name: "Chat", category: "social", share: 0.55 },
  { name: "Messages", category: "social", share: 0.45 },
  { name: "Browser", category: "productivity", share: 0.45 },
  { name: "Mail", category: "productivity", share: 0.3 },
  { name: "Docs", category: "productivity", share: 0.25 },
  { name: "Video", category: "entertainment", share: 0.6 },
  { name: "Music", category: "entertainment", share: 0.4 },
]

const chartConfig = {
  social: { label: "Social", color: "var(--chart-1)" },
  productivity: { label: "Productivity", color: "var(--color-cyan-500)" },
  entertainment: { label: "Entertainment", color: "var(--chart-4)" },
} satisfies ChartConfig

const total = (day: Record<Category, number>) =>
  CATEGORIES.reduce((sum, category) => sum + day[category], 0)

const TAP_SLOP = 10

const AVERAGE = WEEK.reduce((sum, day) => sum + total(day), 0) / WEEK.length

const DAILY_AVERAGE = Object.fromEntries(
  CATEGORIES.map((category) => [
    category,
    WEEK.reduce((sum, day) => sum + day[category], 0) / WEEK.length,
  ])
) as Record<Category, number>

function formatMinutes(minutes: number) {
  const rounded = Math.round(minutes)
  const hours = Math.floor(rounded / 60)
  return hours ? `${hours}h ${rounded % 60}m` : `${rounded}m`
}

function noise(seed: number) {
  const state = (seed * 16807) % 2147483647
  return (state - 1) / 2147483646
}

function distribute(amount: number, weights: number[]) {
  const sum = weights.reduce((total, weight) => total + weight, 0)
  const exact = weights.map((weight) => (sum ? (amount * weight) / sum : 0))
  const parts = exact.map(Math.floor)
  let rest = amount - parts.reduce((total, part) => total + part, 0)
  const order = exact
    .map((value, index) => ({ index, remainder: value - Math.floor(value) }))
    .sort((a, b) => b.remainder - a.remainder)
  for (const { index } of order) {
    if (rest <= 0) break
    parts[index] = (parts[index] ?? 0) + 1
    rest -= 1
  }
  return parts
}

function hourly(day: number) {
  const categories = WEEK[day]
  if (!categories) return []
  const base = PROFILE.map(
    (weight, hour) => weight * (0.75 + noise(hour + 7 * (day + 1)) * 0.5)
  )
  const social = distribute(categories.social, base)
  const productivity = distribute(
    categories.productivity,
    base.map((weight, hour) => weight * (hour > 8 && hour < 18 ? 1.6 : 0.4))
  )
  const entertainment = distribute(
    categories.entertainment,
    base.map((weight, hour) => weight * (hour > 18 ? 1.8 : 0.5))
  )
  return PROFILE.map((_, hour) => ({
    label: String(hour),
    index: hour,
    social: social[hour] ?? 0,
    productivity: productivity[hour] ?? 0,
    entertainment: entertainment[hour] ?? 0,
  }))
}

function KeyboardPoint({
  onChange,
}: {
  onChange: (index: number | null) => void
}) {
  const points = useActiveTooltipDataPoints<{ index?: number }>()
  const index = points?.[0]?.index ?? null

  React.useEffect(() => {
    if (index !== null) onChange(index)
  }, [index, onChange])

  return null
}

export function BarChartInteractive() {
  const [view, setView] = React.useState<"week" | "day">("week")
  const [selected, setSelected] = React.useState<number | null>(null)
  const [highlighted, setHighlighted] = React.useState<number | null>(null)
  const [hoveredApp, setHoveredApp] = React.useState<string | null>(null)
  const touchRef = React.useRef<{ x: number; y: number } | null>(null)

  const isWeek = view === "week"
  const selectedDay = selected === null ? undefined : WEEK[selected]
  const categories = selectedDay ?? DAILY_AVERAGE
  const headline = selectedDay ? total(selectedDay) : AVERAGE
  const data = isWeek
    ? WEEK.map((day, index) => ({
        ...day,
        label: DAYS[index]?.slice(0, 3) ?? "",
        index,
      }))
    : hourly(selected ?? 6)
  const apps = APPS.map((app) => ({
    ...app,
    minutes: categories[app.category] * app.share,
  }))
    .sort((a, b) => b.minutes - a.minutes)
    .slice(0, 5)
  const longest = Math.max(...apps.map((app) => app.minutes))
  const comparison =
    selected === null
      ? "8% less than last week"
      : `${Math.round(Math.abs(headline / AVERAGE - 1) * 100)}% ${headline > AVERAGE ? "above" : "below"} average`

  const select = (index: number) => {
    if (!isWeek) return
    setSelected((current) => (current === index ? null : index))
  }

  return (
    <Card variant="filled" size="sm" className="[--card-spacing:--spacing(5)]">
      <CardHeader className="flex flex-wrap items-start justify-between gap-4">
        <div
          className="flex min-w-52 flex-1 flex-col gap-0.5"
          aria-live="polite"
        >
          <p className="text-sm font-semibold text-muted-foreground">
            {selected === null ? "Daily average" : DAYS[selected]}
          </p>
          <p className="text-3xl font-semibold tracking-tight tabular-nums">
            {formatMinutes(headline)}
          </p>
          <p className="text-sm text-muted-foreground">{comparison}</p>
        </div>
        <ToggleGroup
          spacing={0}
          value={[view]}
          onValueChange={(value: string[]) => {
            const next = value[0] as "week" | "day" | undefined
            if (!next) return
            setView(next)
            if (next === "day" && selected === null) setSelected(6)
          }}
          aria-label="Period"
        >
          <ToggleGroupItem value="week" size="sm" className="px-5">
            Week
          </ToggleGroupItem>
          <ToggleGroupItem value="day" size="sm" className="px-5">
            Day
          </ToggleGroupItem>
        </ToggleGroup>
      </CardHeader>
      <CardContent className="flex flex-col gap-5">
        <ChartContainer
          config={chartConfig}
          className="aspect-auto h-64 w-full data-[view=week]:[&_.recharts-bar-rectangle]:cursor-pointer"
          data-view={view}
          onKeyDown={(event) => {
            if (
              (event.key === "Enter" || event.key === " ") &&
              highlighted !== null
            ) {
              event.preventDefault()
              select(highlighted)
            }
          }}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget)) {
              setHighlighted(null)
            }
          }}
        >
          <BarChart
            accessibilityLayer
            data={data}
            margin={{ top: 10, right: 4, left: 12, bottom: 0 }}
          >
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="label"
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              interval={0}
              ticks={isWeek ? undefined : ["0", "6", "12", "18"]}
              tickFormatter={(value: string) =>
                isWeek ? value : `${value}:00`
              }
            />
            <YAxis
              orientation="right"
              tickLine={false}
              axisLine={false}
              tickMargin={6}
              width="auto"
              ticks={isWeek ? [0, 120, 240, 360] : undefined}
              tickCount={isWeek ? undefined : 4}
              tickFormatter={(value: number) =>
                isWeek ? `${value / 60}h` : `${value}m`
              }
            />
            <ChartTooltip
              cursor={false}
              content={
                <ChartTooltipContent
                  labelFormatter={(_, payload) => {
                    const label = payload[0]?.payload.label
                    return isWeek
                      ? DAYS[DAYS.findIndex((day) => day.startsWith(label))]
                      : `${label}:00 – ${Number(label) + 1}:00`
                  }}
                  formatter={(value, name) => (
                    <div className="flex w-full items-center gap-2">
                      <span
                        className="size-2 shrink-0 rounded-xs bg-(--swatch)"
                        style={
                          {
                            "--swatch": `var(--color-${name})`,
                          } as React.CSSProperties
                        }
                      />
                      <span className="flex-1 text-muted-foreground">
                        {chartConfig[name as Category].label}
                      </span>
                      <span className="font-semibold tabular-nums">
                        {formatMinutes(Number(value))}
                      </span>
                    </div>
                  )}
                />
              }
            />
            {isWeek && (
              <ReferenceLine
                y={AVERAGE}
                stroke="var(--color-green-500)"
                strokeDasharray="3 3"
              />
            )}
            {CATEGORIES.map((category, position) => (
              <Bar
                key={category}
                dataKey={category}
                stackId="a"
                fill={`var(--color-${category})`}
                radius={position === CATEGORIES.length - 1 ? [5, 5, 0, 0] : 0}
                maxBarSize={isWeek ? 34 : 10}
                isAnimationActive={false}
                onClick={(_, index) => select(index)}
                onTouchStart={(_, __, event) => {
                  const touch = event.touches[0]
                  if (!touch) return
                  touchRef.current = { x: touch.clientX, y: touch.clientY }
                }}
                onTouchEnd={(_, index, event) => {
                  const start = touchRef.current
                  const touch = event.changedTouches[0]
                  touchRef.current = null
                  if (
                    !start ||
                    !touch ||
                    Math.hypot(
                      touch.clientX - start.x,
                      touch.clientY - start.y
                    ) > TAP_SLOP
                  )
                    return
                  event.preventDefault()
                  select(index)
                }}
              >
                {isWeek &&
                  data.map((item, index) => (
                    <Cell
                      key={String(item.label)}
                      fillOpacity={
                        selected === null || selected === index ? 1 : 0.3
                      }
                      className="transition-[fill-opacity] duration-200"
                    />
                  ))}
              </Bar>
            ))}
            <KeyboardPoint onChange={setHighlighted} />
          </BarChart>
        </ChartContainer>
        <dl className="grid grid-cols-3 gap-3">
          {CATEGORIES.map((category) => (
            <div key={category} className="flex min-w-0 flex-col gap-0.5">
              <dt className="flex min-w-0 items-center gap-1.5 text-sm text-muted-foreground">
                <span
                  className="size-2 shrink-0 rounded-full bg-(--swatch)"
                  style={
                    {
                      "--swatch": chartConfig[category].color,
                    } as React.CSSProperties
                  }
                />
                <span className="truncate">{chartConfig[category].label}</span>
              </dt>
              <dd className="text-base font-semibold tabular-nums">
                {formatMinutes(categories[category])}
              </dd>
            </div>
          ))}
        </dl>
        <div className="flex flex-col">
          <p className="mb-1 text-xs font-semibold text-muted-foreground uppercase">
            Most used
          </p>
          <ul>
            {apps.map((app) => (
              <li
                key={app.name}
                onPointerEnter={() => setHoveredApp(app.name)}
                onPointerLeave={() => setHoveredApp(null)}
                className="grid grid-cols-[6rem_1fr_4.5rem] items-center gap-3 border-t border-border py-2"
              >
                <span className="truncate text-base">{app.name}</span>
                <span className="h-1.5 overflow-hidden rounded-full bg-secondary">
                  <span
                    data-dimmed={
                      hoveredApp !== null && hoveredApp !== app.name
                        ? ""
                        : undefined
                    }
                    className="block h-full origin-left rounded-full rtl:origin-right bg-(--swatch) transition-[scale,opacity] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] data-dimmed:opacity-40"
                    style={
                      {
                        "--swatch": chartConfig[app.category].color,
                        scale: `${app.minutes / longest} 1`,
                      } as React.CSSProperties
                    }
                  />
                </span>
                <span className="text-end text-sm text-muted-foreground tabular-nums">
                  {formatMinutes(app.minutes)}
                </span>
              </li>
            ))}
          </ul>
        </div>
      </CardContent>
    </Card>
  )
}
```

A full screen-time report: stacked category bars per day, a Week and Day toggle, selectable days, per-category totals and a most-used apps list that all update together. Use it as a reference for building a drill-down dashboard on top of a bar chart.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/bar-chart-interactive
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add charts bar-chart-interactive
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install recharts
```

2. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/charts/bar-chart-interactive.tsx`:

```tsx title="components/charts/bar-chart-interactive.tsx"
"use client"

import * as React from "react"
import {
  Bar,
  BarChart,
  CartesianGrid,
  Cell,
  ReferenceLine,
  useActiveTooltipDataPoints,
  XAxis,
  YAxis,
} from "recharts"

import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Category = "social" | "productivity" | "entertainment"

const CATEGORIES: Category[] = ["social", "productivity", "entertainment"]

const DAYS = [
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
  "Sunday",
]

const USAGE: [social: number, productivity: number, entertainment: number][] = [
  [48, 95, 62],
  [55, 120, 40],
  [70, 88, 95],
  [42, 130, 35],
  [65, 100, 80],
  [110, 30, 140],
  [95, 25, 120],
]

const WEEK: Record<Category, number>[] = USAGE.map(
  ([social, productivity, entertainment]) => ({
    social,
    productivity,
    entertainment,
  })
)

const PROFILE = [
  1, 0, 0, 0, 0, 0, 1, 4, 6, 5, 4, 5, 7, 6, 5, 4, 5, 6, 7, 9, 10, 11, 8, 4,
]

const APPS: { name: string; category: Category; share: number }[] = [
  { name: "Chat", category: "social", share: 0.55 },
  { name: "Messages", category: "social", share: 0.45 },
  { name: "Browser", category: "productivity", share: 0.45 },
  { name: "Mail", category: "productivity", share: 0.3 },
  { name: "Docs", category: "productivity", share: 0.25 },
  { name: "Video", category: "entertainment", share: 0.6 },
  { name: "Music", category: "entertainment", share: 0.4 },
]

const chartConfig = {
  social: { label: "Social", color: "var(--chart-1)" },
  productivity: { label: "Productivity", color: "var(--color-cyan-500)" },
  entertainment: { label: "Entertainment", color: "var(--chart-4)" },
} satisfies ChartConfig

const total = (day: Record<Category, number>) =>
  CATEGORIES.reduce((sum, category) => sum + day[category], 0)

const TAP_SLOP = 10

const AVERAGE = WEEK.reduce((sum, day) => sum + total(day), 0) / WEEK.length

const DAILY_AVERAGE = Object.fromEntries(
  CATEGORIES.map((category) => [
    category,
    WEEK.reduce((sum, day) => sum + day[category], 0) / WEEK.length,
  ])
) as Record<Category, number>

function formatMinutes(minutes: number) {
  const rounded = Math.round(minutes)
  const hours = Math.floor(rounded / 60)
  return hours ? `${hours}h ${rounded % 60}m` : `${rounded}m`
}

function noise(seed: number) {
  const state = (seed * 16807) % 2147483647
  return (state - 1) / 2147483646
}

function distribute(amount: number, weights: number[]) {
  const sum = weights.reduce((total, weight) => total + weight, 0)
  const exact = weights.map((weight) => (sum ? (amount * weight) / sum : 0))
  const parts = exact.map(Math.floor)
  let rest = amount - parts.reduce((total, part) => total + part, 0)
  const order = exact
    .map((value, index) => ({ index, remainder: value - Math.floor(value) }))
    .sort((a, b) => b.remainder - a.remainder)
  for (const { index } of order) {
    if (rest <= 0) break
    parts[index] = (parts[index] ?? 0) + 1
    rest -= 1
  }
  return parts
}

function hourly(day: number) {
  const categories = WEEK[day]
  if (!categories) return []
  const base = PROFILE.map(
    (weight, hour) => weight * (0.75 + noise(hour + 7 * (day + 1)) * 0.5)
  )
  const social = distribute(categories.social, base)
  const productivity = distribute(
    categories.productivity,
    base.map((weight, hour) => weight * (hour > 8 && hour < 18 ? 1.6 : 0.4))
  )
  const entertainment = distribute(
    categories.entertainment,
    base.map((weight, hour) => weight * (hour > 18 ? 1.8 : 0.5))
  )
  return PROFILE.map((_, hour) => ({
    label: String(hour),
    index: hour,
    social: social[hour] ?? 0,
    productivity: productivity[hour] ?? 0,
    entertainment: entertainment[hour] ?? 0,
  }))
}

function KeyboardPoint({
  onChange,
}: {
  onChange: (index: number | null) => void
}) {
  const points = useActiveTooltipDataPoints<{ index?: number }>()
  const index = points?.[0]?.index ?? null

  React.useEffect(() => {
    if (index !== null) onChange(index)
  }, [index, onChange])

  return null
}

export function BarChartInteractive() {
  const [view, setView] = React.useState<"week" | "day">("week")
  const [selected, setSelected] = React.useState<number | null>(null)
  const [highlighted, setHighlighted] = React.useState<number | null>(null)
  const [hoveredApp, setHoveredApp] = React.useState<string | null>(null)
  const touchRef = React.useRef<{ x: number; y: number } | null>(null)

  const isWeek = view === "week"
  const selectedDay = selected === null ? undefined : WEEK[selected]
  const categories = selectedDay ?? DAILY_AVERAGE
  const headline = selectedDay ? total(selectedDay) : AVERAGE
  const data = isWeek
    ? WEEK.map((day, index) => ({
        ...day,
        label: DAYS[index]?.slice(0, 3) ?? "",
        index,
      }))
    : hourly(selected ?? 6)
  const apps = APPS.map((app) => ({
    ...app,
    minutes: categories[app.category] * app.share,
  }))
    .sort((a, b) => b.minutes - a.minutes)
    .slice(0, 5)
  const longest = Math.max(...apps.map((app) => app.minutes))
  const comparison =
    selected === null
      ? "8% less than last week"
      : `${Math.round(Math.abs(headline / AVERAGE - 1) * 100)}% ${headline > AVERAGE ? "above" : "below"} average`

  const select = (index: number) => {
    if (!isWeek) return
    setSelected((current) => (current === index ? null : index))
  }

  return (
    <Card variant="filled" size="sm" className="[--card-spacing:--spacing(5)]">
      <CardHeader className="flex flex-wrap items-start justify-between gap-4">
        <div
          className="flex min-w-52 flex-1 flex-col gap-0.5"
          aria-live="polite"
        >
          <p className="text-sm font-semibold text-muted-foreground">
            {selected === null ? "Daily average" : DAYS[selected]}
          </p>
          <p className="text-3xl font-semibold tracking-tight tabular-nums">
            {formatMinutes(headline)}
          </p>
          <p className="text-sm text-muted-foreground">{comparison}</p>
        </div>
        <ToggleGroup
          spacing={0}
          value={[view]}
          onValueChange={(value: string[]) => {
            const next = value[0] as "week" | "day" | undefined
            if (!next) return
            setView(next)
            if (next === "day" && selected === null) setSelected(6)
          }}
          aria-label="Period"
        >
          <ToggleGroupItem value="week" size="sm" className="px-5">
            Week
          </ToggleGroupItem>
          <ToggleGroupItem value="day" size="sm" className="px-5">
            Day
          </ToggleGroupItem>
        </ToggleGroup>
      </CardHeader>
      <CardContent className="flex flex-col gap-5">
        <ChartContainer
          config={chartConfig}
          className="aspect-auto h-64 w-full data-[view=week]:[&_.recharts-bar-rectangle]:cursor-pointer"
          data-view={view}
          onKeyDown={(event) => {
            if (
              (event.key === "Enter" || event.key === " ") &&
              highlighted !== null
            ) {
              event.preventDefault()
              select(highlighted)
            }
          }}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget)) {
              setHighlighted(null)
            }
          }}
        >
          <BarChart
            accessibilityLayer
            data={data}
            margin={{ top: 10, right: 4, left: 12, bottom: 0 }}
          >
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="label"
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              interval={0}
              ticks={isWeek ? undefined : ["0", "6", "12", "18"]}
              tickFormatter={(value: string) =>
                isWeek ? value : `${value}:00`
              }
            />
            <YAxis
              orientation="right"
              tickLine={false}
              axisLine={false}
              tickMargin={6}
              width="auto"
              ticks={isWeek ? [0, 120, 240, 360] : undefined}
              tickCount={isWeek ? undefined : 4}
              tickFormatter={(value: number) =>
                isWeek ? `${value / 60}h` : `${value}m`
              }
            />
            <ChartTooltip
              cursor={false}
              content={
                <ChartTooltipContent
                  labelFormatter={(_, payload) => {
                    const label = payload[0]?.payload.label
                    return isWeek
                      ? DAYS[DAYS.findIndex((day) => day.startsWith(label))]
                      : `${label}:00 – ${Number(label) + 1}:00`
                  }}
                  formatter={(value, name) => (
                    <div className="flex w-full items-center gap-2">
                      <span
                        className="size-2 shrink-0 rounded-xs bg-(--swatch)"
                        style={
                          {
                            "--swatch": `var(--color-${name})`,
                          } as React.CSSProperties
                        }
                      />
                      <span className="flex-1 text-muted-foreground">
                        {chartConfig[name as Category].label}
                      </span>
                      <span className="font-semibold tabular-nums">
                        {formatMinutes(Number(value))}
                      </span>
                    </div>
                  )}
                />
              }
            />
            {isWeek && (
              <ReferenceLine
                y={AVERAGE}
                stroke="var(--color-green-500)"
                strokeDasharray="3 3"
              />
            )}
            {CATEGORIES.map((category, position) => (
              <Bar
                key={category}
                dataKey={category}
                stackId="a"
                fill={`var(--color-${category})`}
                radius={position === CATEGORIES.length - 1 ? [5, 5, 0, 0] : 0}
                maxBarSize={isWeek ? 34 : 10}
                isAnimationActive={false}
                onClick={(_, index) => select(index)}
                onTouchStart={(_, __, event) => {
                  const touch = event.touches[0]
                  if (!touch) return
                  touchRef.current = { x: touch.clientX, y: touch.clientY }
                }}
                onTouchEnd={(_, index, event) => {
                  const start = touchRef.current
                  const touch = event.changedTouches[0]
                  touchRef.current = null
                  if (
                    !start ||
                    !touch ||
                    Math.hypot(
                      touch.clientX - start.x,
                      touch.clientY - start.y
                    ) > TAP_SLOP
                  )
                    return
                  event.preventDefault()
                  select(index)
                }}
              >
                {isWeek &&
                  data.map((item, index) => (
                    <Cell
                      key={String(item.label)}
                      fillOpacity={
                        selected === null || selected === index ? 1 : 0.3
                      }
                      className="transition-[fill-opacity] duration-200"
                    />
                  ))}
              </Bar>
            ))}
            <KeyboardPoint onChange={setHighlighted} />
          </BarChart>
        </ChartContainer>
        <dl className="grid grid-cols-3 gap-3">
          {CATEGORIES.map((category) => (
            <div key={category} className="flex min-w-0 flex-col gap-0.5">
              <dt className="flex min-w-0 items-center gap-1.5 text-sm text-muted-foreground">
                <span
                  className="size-2 shrink-0 rounded-full bg-(--swatch)"
                  style={
                    {
                      "--swatch": chartConfig[category].color,
                    } as React.CSSProperties
                  }
                />
                <span className="truncate">{chartConfig[category].label}</span>
              </dt>
              <dd className="text-base font-semibold tabular-nums">
                {formatMinutes(categories[category])}
              </dd>
            </div>
          ))}
        </dl>
        <div className="flex flex-col">
          <p className="mb-1 text-xs font-semibold text-muted-foreground uppercase">
            Most used
          </p>
          <ul>
            {apps.map((app) => (
              <li
                key={app.name}
                onPointerEnter={() => setHoveredApp(app.name)}
                onPointerLeave={() => setHoveredApp(null)}
                className="grid grid-cols-[6rem_1fr_4.5rem] items-center gap-3 border-t border-border py-2"
              >
                <span className="truncate text-base">{app.name}</span>
                <span className="h-1.5 overflow-hidden rounded-full bg-secondary">
                  <span
                    data-dimmed={
                      hoveredApp !== null && hoveredApp !== app.name
                        ? ""
                        : undefined
                    }
                    className="block h-full origin-left rounded-full rtl:origin-right bg-(--swatch) transition-[scale,opacity] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] data-dimmed:opacity-40"
                    style={
                      {
                        "--swatch": chartConfig[app.category].color,
                        scale: `${app.minutes / longest} 1`,
                      } as React.CSSProperties
                    }
                  />
                </span>
                <span className="text-end text-sm text-muted-foreground tabular-nums">
                  {formatMinutes(app.minutes)}
                </span>
              </li>
            ))}
          </ul>
        </div>
      </CardContent>
    </Card>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { BarChartInteractive } from "@/components/charts/bar-chart-interactive"
```

```tsx
<BarChartInteractive />
```

Replace `USAGE` with your per-day category totals and `APPS` with your item breakdown, and rename the categories in `CATEGORIES` and `chartConfig`. The hourly Day view is generated from the daily totals for the demo, so feed it real hourly data in production. The comparison line under the headline is a fixed string until a day is selected.

## Notes

Click, tap or press Return on a bar to select a day, and select it again to clear the selection; taps that move like a scroll are ignored. Day view shows Sunday if no day was selected. The header is a live region so selection changes are announced.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/charts-bar--interactive.light.webp)

```tsx title="uniwind/components/charts/bar-chart-interactive.tsx"
import { cn } from "cn"
import * as React from "react"
import { View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  Bar,
  BarChart,
  CartesianGrid,
  Cell,
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  ReferenceLine,
  XAxis,
  YAxis,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Category = "social" | "productivity" | "entertainment"

type Period = "week" | "day"

const CATEGORIES: Category[] = ["social", "productivity", "entertainment"]

const DAYS = [
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
  "Sunday",
]

const USAGE: [social: number, productivity: number, entertainment: number][] = [
  [48, 95, 62],
  [55, 120, 40],
  [70, 88, 95],
  [42, 130, 35],
  [65, 100, 80],
  [110, 30, 140],
  [95, 25, 120],
]

const WEEK: Record<Category, number>[] = USAGE.map(
  ([social, productivity, entertainment]) => ({
    social,
    productivity,
    entertainment,
  })
)

const PROFILE = [
  1, 0, 0, 0, 0, 0, 1, 4, 6, 5, 4, 5, 7, 6, 5, 4, 5, 6, 7, 9, 10, 11, 8, 4,
]

const APPS: { name: string; category: Category; share: number }[] = [
  { name: "Chat", category: "social", share: 0.55 },
  { name: "Messages", category: "social", share: 0.45 },
  { name: "Browser", category: "productivity", share: 0.45 },
  { name: "Mail", category: "productivity", share: 0.3 },
  { name: "Docs", category: "productivity", share: 0.25 },
  { name: "Video", category: "entertainment", share: 0.6 },
  { name: "Music", category: "entertainment", share: 0.4 },
]

const chartConfig = {
  social: { label: "Social", color: "chart-1" },
  productivity: { label: "Productivity", color: "cyan-500" },
  entertainment: { label: "Entertainment", color: "chart-4" },
} satisfies ChartConfig

const SWATCH: Record<Category, string> = {
  social: "bg-blue-500",
  productivity: "bg-cyan-500",
  entertainment: "bg-orange-500",
}

const total = (day: Record<Category, number>) =>
  CATEGORIES.reduce((sum, category) => sum + day[category], 0)

const AVERAGE = WEEK.reduce((sum, day) => sum + total(day), 0) / WEEK.length

const DAILY_AVERAGE = Object.fromEntries(
  CATEGORIES.map((category) => [
    category,
    WEEK.reduce((sum, day) => sum + day[category], 0) / WEEK.length,
  ])
) as Record<Category, number>

function formatMinutes(minutes: number) {
  const rounded = Math.round(minutes)
  const hours = Math.floor(rounded / 60)
  return hours ? `${hours}h ${rounded % 60}m` : `${rounded}m`
}

function noise(seed: number) {
  const state = (seed * 16807) % 2147483647
  return (state - 1) / 2147483646
}

function hourly(day: number) {
  const profileTotal = PROFILE.reduce((sum, value) => sum + value, 0)
  const categories = WEEK[day]
  if (!categories) return []
  return PROFILE.map((weight, hour) => {
    const share =
      (weight / profileTotal) * (0.75 + noise(hour + 7 * (day + 1)) * 0.5)
    return {
      label: String(hour),
      index: hour,
      social: Math.round(categories.social * share),
      productivity: Math.round(
        categories.productivity * share * (hour > 8 && hour < 18 ? 1.6 : 0.4)
      ),
      entertainment: Math.round(
        categories.entertainment * share * (hour > 18 ? 1.8 : 0.5)
      ),
    }
  })
}

export function BarChartInteractive() {
  const [view, setView] = React.useState<Period>("week")
  const [selected, setSelected] = React.useState<number | null>(null)

  const isWeek = view === "week"
  const selectedDay = selected === null ? undefined : WEEK[selected]
  const categories = selectedDay ?? DAILY_AVERAGE
  const headline = selectedDay ? total(selectedDay) : AVERAGE
  const data = isWeek
    ? WEEK.map((day, index) => ({
        ...day,
        label: DAYS[index]?.slice(0, 3) ?? "",
        index,
      }))
    : hourly(selected ?? 6)
  const apps = APPS.map((app) => ({
    ...app,
    minutes: categories[app.category] * app.share,
  }))
    .sort((a, b) => b.minutes - a.minutes)
    .slice(0, 5)
  const longest = Math.max(...apps.map((app) => app.minutes))
  const comparison =
    selected === null
      ? "8% less than last week"
      : `${Math.round(Math.abs(headline / AVERAGE - 1) * 100)}% ${headline > AVERAGE ? "above" : "below"} average`

  return (
    <Card variant="filled" size="sm">
      <CardHeader className="flex-row flex-wrap items-start justify-between gap-4">
        <View
          className="min-w-52 grow gap-0.5"
          accessibilityLiveRegion="polite"
        >
          <Text className="text-sm font-semibold text-muted-foreground">
            {selected === null ? "Daily average" : DAYS[selected]}
          </Text>
          <Text className="text-3xl font-semibold tracking-tight tabular-nums">
            {formatMinutes(headline)}
          </Text>
          <Text className="text-sm text-muted-foreground">{comparison}</Text>
        </View>
        <ToggleGroup
          spacing={0}
          value={[view]}
          onValueChange={(value) => {
            const next = value[0] as Period | undefined
            if (!next) return
            setView(next)
            if (next === "day" && selected === null) setSelected(6)
          }}
          accessibilityLabel="Period"
        >
          <ToggleGroupItem value="week" size="sm" className="px-5">
            <Text>Week</Text>
          </ToggleGroupItem>
          <ToggleGroupItem value="day" size="sm" className="px-5">
            <Text>Day</Text>
          </ToggleGroupItem>
        </ToggleGroup>
      </CardHeader>
      <CardContent className="gap-5">
        <ChartContainer
          config={chartConfig}
          accessibilityLabel="Screen time"
          className="h-64 w-full"
        >
          <BarChart
            key={view}
            data={data}
            margin={{ top: 10, right: 4, left: 12, bottom: 0 }}
            onActiveIndexChange={isWeek ? setSelected : undefined}
          >
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="label"
              tickMargin={8}
              interval={0}
              ticks={isWeek ? undefined : ["0", "6", "12", "18"]}
              tickFormatter={(value) => (isWeek ? `${value}` : `${value}:00`)}
            />
            <YAxis
              orientation="right"
              tickMargin={6}
              width="auto"
              ticks={isWeek ? [0, 120, 240, 360] : undefined}
              tickCount={isWeek ? undefined : 4}
              tickFormatter={(value) =>
                isWeek ? `${Number(value) / 60}h` : `${value}m`
              }
            />
            <ChartTooltip
              cursor={false}
              content={
                <ChartTooltipContent
                  labelFormatter={(_, payload) => {
                    const label = String(payload[0]?.payload.label ?? "")
                    return isWeek
                      ? DAYS.find((day) => day.startsWith(label))
                      : `${label}:00 – ${Number(label) + 1}:00`
                  }}
                  formatter={(value, name) => (
                    <View className="w-full flex-row items-center gap-2">
                      <View
                        className={cn(
                          "size-2 shrink-0 rounded-xs",
                          SWATCH[name as Category]
                        )}
                      />
                      <Text className="grow text-muted-foreground">
                        {chartConfig[name as Category].label}
                      </Text>
                      <Text className="font-semibold tabular-nums">
                        {formatMinutes(Number(value))}
                      </Text>
                    </View>
                  )}
                />
              }
            />
            {isWeek ? (
              <ReferenceLine
                y={AVERAGE}
                stroke="green-500"
                strokeDasharray="3 3"
              />
            ) : null}
            {CATEGORIES.map((category, position) => (
              <Bar
                isAnimationActive={false}
                key={category}
                dataKey={category}
                stackId="a"
                fill={chartConfig[category].color}
                radius={position === CATEGORIES.length - 1 ? [5, 5, 0, 0] : 0}
                maxBarSize={isWeek ? 34 : 10}
              >
                {isWeek
                  ? data.map((item, index) => (
                      <Cell
                        key={item.label}
                        fillOpacity={
                          selected === null || selected === index ? 1 : 0.3
                        }
                      />
                    ))
                  : null}
              </Bar>
            ))}
          </BarChart>
        </ChartContainer>
        <View className="flex-row gap-3">
          {CATEGORIES.map((category) => (
            <View key={category} accessible className="min-w-0 flex-1 gap-0.5">
              <View className="min-w-0 flex-row items-center gap-1.5">
                <View
                  className={cn(
                    "size-2 shrink-0 rounded-full",
                    SWATCH[category]
                  )}
                />
                <Text
                  numberOfLines={1}
                  className="shrink text-sm text-muted-foreground"
                >
                  {chartConfig[category].label}
                </Text>
              </View>
              <Text className="text-base font-semibold tabular-nums">
                {formatMinutes(categories[category])}
              </Text>
            </View>
          ))}
        </View>
        <View>
          <Text className="mb-1 text-xs font-semibold text-muted-foreground uppercase">
            Most used
          </Text>
          {apps.map((app) => (
            <View
              key={app.name}
              accessible
              className="flex-row items-center gap-3 border-t border-border py-2"
            >
              <Text numberOfLines={1} className="w-24 text-base">
                {app.name}
              </Text>
              <View className="h-1.5 grow overflow-hidden rounded-full bg-secondary">
                <View
                  style={{ width: `${(app.minutes / longest) * 100}%` }}
                  className={cn("h-full rounded-full", SWATCH[app.category])}
                />
              </View>
              <Text className="w-18 text-right text-sm text-muted-foreground tabular-nums">
                {formatMinutes(app.minutes)}
              </Text>
            </View>
          ))}
        </View>
      </CardContent>
    </Card>
  )
}
```

```swift title="swift/Charts/BarChartInteractive.swift"
import Charts
import SwiftUI

private enum Category: String, CaseIterable {
  case social, productivity, entertainment
}

private enum Period: String, CaseIterable {
  case week, day
}

private let days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]

private let usage: [[Double]] = [
  [48, 95, 62], [55, 120, 40], [70, 88, 95], [42, 130, 35], [65, 100, 80], [110, 30, 140], [95, 25, 120],
]

private let profile: [Double] = [1, 0, 0, 0, 0, 0, 1, 4, 6, 5, 4, 5, 7, 6, 5, 4, 5, 6, 7, 9, 10, 11, 8, 4]

private let apps: [(name: String, category: Category, share: Double)] = [
  ("Chat", .social, 0.55),
  ("Messages", .social, 0.45),
  ("Browser", .productivity, 0.45),
  ("Mail", .productivity, 0.3),
  ("Docs", .productivity, 0.25),
  ("Video", .entertainment, 0.6),
  ("Music", .entertainment, 0.4),
]

private let chartConfig: ChartConfig = [
  "social": .init("Social", color: EsuissColor.chart1),
  "productivity": .init("Productivity", color: Palette.cyan500),
  "entertainment": .init("Entertainment", color: EsuissColor.chart4),
]

private func week(_ day: Int, _ category: Category) -> Double {
  usage[day][Category.allCases.firstIndex(of: category) ?? 0]
}

private func total(_ day: Int) -> Double {
  Category.allCases.reduce(0) { $0 + week(day, $1) }
}

private let averageTotal = days.indices.reduce(0) { $0 + total($1) } / Double(days.count)

private func dailyAverage(_ category: Category) -> Double {
  days.indices.reduce(0) { $0 + week($1, category) } / Double(days.count)
}

private func formatMinutes(_ minutes: Double) -> String {
  let rounded = Int(minutes.rounded())
  let hours = rounded / 60
  return hours > 0 ? "\(hours)h \(rounded % 60)m" : "\(rounded)m"
}

private func noise(_ seed: Int) -> Double {
  Double((seed * 16807) % 2147483647 - 1) / 2147483646
}

private func hourly(_ day: Int, _ hour: Int, _ category: Category) -> Double {
  let profileTotal = profile.reduce(0, +)
  let share = profile[hour] / profileTotal * (0.75 + noise(hour + 7 * (day + 1)) * 0.5)
  switch category {
  case .social: return (week(day, .social) * share).rounded()
  case .productivity: return (week(day, .productivity) * share * (hour > 8 && hour < 18 ? 1.6 : 0.4)).rounded()
  case .entertainment: return (week(day, .entertainment) * share * (hour > 18 ? 1.8 : 0.5)).rounded()
  }
}

public struct BarChartInteractive: View {
  @State private var view: Period = .week
  @State private var selected: Int?
  @State private var hour: Int?
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init() {}

  private var isWeek: Bool { view == .week }

  private var labels: [String] {
    isWeek ? days.map { String($0.prefix(3)) } : profile.indices.map { "\($0)" }
  }

  private func value(_ index: Int, _ category: Category) -> Double {
    isWeek ? week(index, category) : hourly(selected ?? 6, index, category)
  }

  private var viewBinding: Binding<Period?> {
    Binding(get: { view }, set: { next in
      guard let next else { return }
      view = next
      hour = nil
      if next == .day && selected == nil { selected = 6 }
    })
  }

  private var selection: Binding<Int?> {
    isWeek ? $selected : $hour
  }

  public var body: some View {
    let categories: (Category) -> Double = { category in
      selected.map { week($0, category) } ?? dailyAverage(category)
    }
    let headline = selected.map(total) ?? averageTotal
    let ranked = apps.map { (app: $0, minutes: categories($0.category) * $0.share) }
      .sorted { $0.minutes > $1.minutes }
      .prefix(5)
    let longest = ranked.map(\.minutes).max() ?? 1
    let comparison = selected == nil
      ? "8% less than last week"
      : "\(Int((abs(headline / averageTotal - 1) * 100).rounded()))% \(headline > averageTotal ? "above" : "below") average"
    Card(.filled, size: .sm) {
      VStack(alignment: .leading, spacing: 16) {
        VStack(alignment: .leading, spacing: 2) {
          Text(LocalizedStringKey(selected.map { days[$0] } ?? "Daily average"))
            .textStyle(.sm, weight: .semibold)
            .foregroundStyle(EsuissColor.mutedForeground)
          Text(verbatim: formatMinutes(headline))
            .textStyle(.xl3, weight: .semibold)
            .monospacedDigit()
          Text(LocalizedStringKey(comparison))
            .textStyle(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
        }
        .accessibilityElement(children: .combine)
        ToggleGroup(selection: viewBinding, size: .sm, spacing: 0) {
          ToggleGroupItem(value: Period.week) { Text("Week").padding(.horizontal, 8) }
          ToggleGroupItem(value: Period.day) { Text("Day").padding(.horizontal, 8) }
        }
        .accessibilityLabel("Period")
      }
      .padding(.horizontal, 16)
      CardContent {
        VStack(alignment: .leading, spacing: 20) {
          chart
          LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12, alignment: .leading), count: 3), spacing: 12) {
            ForEach(Category.allCases, id: \.self) { category in
              VStack(alignment: .leading, spacing: 2) {
                HStack(spacing: 6) {
                  Circle()
                    .fill(chartConfig.color(category.rawValue))
                    .frame(width: 8, height: 8)
                  Text(LocalizedStringKey(chartConfig.label(category.rawValue)))
                    .textStyle(.sm)
                    .foregroundStyle(EsuissColor.mutedForeground)
                    .lineLimit(1)
                }
                Text(verbatim: formatMinutes(categories(category)))
                  .textStyle(.base, weight: .semibold)
                  .monospacedDigit()
              }
              .accessibilityElement(children: .combine)
            }
          }
          VStack(alignment: .leading, spacing: 0) {
            Text("MOST USED")
              .textStyle(.xs, weight: .semibold)
              .foregroundStyle(EsuissColor.mutedForeground)
              .padding(.bottom, 4)
            ForEach(Array(ranked), id: \.app.name) { entry in
              HStack(spacing: 12) {
                Text(LocalizedStringKey(entry.app.name))
                  .textStyle(.base)
                  .lineLimit(1)
                  .frame(width: 96, alignment: .leading)
                GeometryReader { geometry in
                  Capsule()
                    .fill(EsuissColor.secondary)
                    .overlay(alignment: .leading) {
                      Capsule()
                        .fill(chartConfig.color(entry.app.category.rawValue))
                        .frame(width: geometry.size.width * entry.minutes / longest)
                    }
                }
                .frame(height: 6)
                Text(verbatim: formatMinutes(entry.minutes))
                  .textStyle(.sm)
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .monospacedDigit()
                  .frame(width: 72, alignment: .trailing)
              }
              .padding(.vertical, 8)
              .overlay(alignment: .top) {
                EsuissColor.border.frame(height: 1)
              }
              .accessibilityElement(children: .combine)
            }
          }
          .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.4), value: selected)
        }
      }
    }
  }

  private var chart: some View {
    let labels = labels
    return ChartContainer(config: chartConfig) {
      Chart {
        ForEach(labels.indices, id: \.self) { index in
          let dimmed = isWeek && selected != nil && selected != index
          ForEach(Category.allCases, id: \.self) { category in
            let start = Category.allCases.prefix { $0 != category }.reduce(0) { $0 + value(index, $1) }
            BarMark(
              x: .value("Time", labels[index]),
              yStart: .value("Start", start),
              yEnd: .value("End", start + value(index, category)),
              width: .fixed(isWeek ? 34 : 10)
            )
            .clipShape(UnevenRoundedRectangle(
              topLeadingRadius: category == .entertainment ? 5 : 0,
              topTrailingRadius: category == .entertainment ? 5 : 0,
              style: .continuous
            ))
            .foregroundStyle(chartConfig.color(category.rawValue).opacity(dimmed ? 0.3 : 1))
          }
        }
        if isWeek {
          RuleMark(y: .value("Average", averageTotal))
            .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
            .foregroundStyle(Palette.green500)
        }
      }
      .chartXAxis {
        ChartAxis.x(values: isWeek ? labels : ["0", "6", "12", "18"]) { isWeek ? $0 : "\($0):00" }
      }
      .chartYAxis {
        if isWeek {
          ChartAxis.y(values: [0.0, 120, 240, 360]) { "\(Int($0 / 60))h" }
        } else {
          ChartAxis.y(count: 4) { "\(Int($0))m" }
        }
      }
      .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: selected)
      .chartTooltip(
        x: labels,
        labels: isWeek ? days : labels.map { "\($0):00 – \((Int($0) ?? 0) + 1):00" },
        selection: selection,
        cursor: false,
        accessibilityLabel: "Screen time",
        items: { index in Category.allCases.map { ChartTooltipItem($0.rawValue, value: value(index, $0)) } }
      ) { payload in
        ChartTooltipContent(payload) { item in
          RoundedRectangle(cornerRadius: 2, style: .continuous)
            .fill(chartConfig.color(item.key))
            .frame(width: 8, height: 8)
          Text(LocalizedStringKey(chartConfig.label(item.key)))
            .foregroundStyle(EsuissColor.mutedForeground)
            .frame(maxWidth: .infinity, alignment: .leading)
          Text(verbatim: formatMinutes(item.value))
            .scaledFont(TextStyle.xs, weight: .semibold)
            .monospacedDigit()
        }
      }
      .id(view)
    }
    .frame(height: 256)
  }
}
```

```kt title="kotlin/charts/BarChartInteractive.kt"
package com.esuiss.ui.charts

import androidx.compose.animation.core.animateFloatAsState
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.BarChart
import com.esuiss.ui.components.Card
import com.esuiss.ui.components.CardContent
import com.esuiss.ui.components.CardSize
import com.esuiss.ui.components.CardVariant
import com.esuiss.ui.components.ChartAxisOrientation
import com.esuiss.ui.components.ChartCell
import com.esuiss.ui.components.ChartConfig
import com.esuiss.ui.components.ChartConfigItem
import com.esuiss.ui.components.ChartContainer
import com.esuiss.ui.components.ChartMargin
import com.esuiss.ui.components.ChartRadius
import com.esuiss.ui.components.ChartTooltipContent
import com.esuiss.ui.components.Separator
import com.esuiss.ui.components.ToggleGroup
import com.esuiss.ui.components.ToggleGroupItem
import com.esuiss.ui.components.ToggleSize
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlin.math.abs
import kotlin.math.roundToInt

private val categories = listOf("social", "productivity", "entertainment")

private val days = listOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")

private val week: List<Map<String, Double>> = listOf(
    listOf(48, 95, 62),
    listOf(55, 120, 40),
    listOf(70, 88, 95),
    listOf(42, 130, 35),
    listOf(65, 100, 80),
    listOf(110, 30, 140),
    listOf(95, 25, 120),
).map { usage -> categories.zip(usage.map { it.toDouble() }).toMap() }

private val profile = listOf(1, 0, 0, 0, 0, 0, 1, 4, 6, 5, 4, 5, 7, 6, 5, 4, 5, 6, 7, 9, 10, 11, 8, 4)

private class AppUsage(val name: String, val category: String, val share: Double)

private val apps = listOf(
    AppUsage("Chat", "social", 0.55),
    AppUsage("Messages", "social", 0.45),
    AppUsage("Browser", "productivity", 0.45),
    AppUsage("Mail", "productivity", 0.3),
    AppUsage("Docs", "productivity", 0.25),
    AppUsage("Video", "entertainment", 0.6),
    AppUsage("Music", "entertainment", 0.4),
)

private val chartConfig = ChartConfig(
    "social" to ChartConfigItem("Social") { chart1 },
    "productivity" to ChartConfigItem("Productivity") { Palette.cyan500 },
    "entertainment" to ChartConfigItem("Entertainment") { chart4 },
)

private class UsageRow(val label: String, val values: Map<String, Double>)

private fun total(day: Map<String, Double>) = categories.sumOf { day.getValue(it) }

private val average = week.sumOf { total(it) } / week.size

private val dailyAverage = categories.associateWith { category -> week.sumOf { it.getValue(category) } / week.size }

private fun formatMinutes(minutes: Double): String {
    val rounded = minutes.roundToInt()
    val hours = rounded / 60
    return if (hours > 0) "${hours}h ${rounded % 60}m" else "${rounded}m"
}

private fun noise(seed: Int): Double {
    val state = seed.toLong() * 16807 % 2147483647
    return (state - 1) / 2147483646.0
}

private fun hourly(day: Int): List<UsageRow> {
    val profileTotal = profile.sum().toDouble()
    val values = week[day]
    return profile.mapIndexed { hour, weight ->
        val share = weight / profileTotal * (0.75 + noise(hour + 7 * (day + 1)) * 0.5)
        UsageRow(
            hour.toString(),
            mapOf(
                "social" to (values.getValue("social") * share).roundToInt().toDouble(),
                "productivity" to (values.getValue("productivity") * share * if (hour in 9..17) 1.6 else 0.4).roundToInt().toDouble(),
                "entertainment" to (values.getValue("entertainment") * share * if (hour > 18) 1.8 else 0.5).roundToInt().toDouble(),
            ),
        )
    }
}

@Composable
fun BarChartInteractive(modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    var view by remember { mutableStateOf("week") }
    var selected by remember { mutableStateOf<Int?>(null) }
    var hour by remember { mutableStateOf<Int?>(null) }
    val isWeek = view == "week"
    val selectedDay = selected?.let { week[it] }
    val values = selectedDay ?: dailyAverage
    val headline = selectedDay?.let(::total) ?: average
    val data = if (isWeek) {
        week.mapIndexed { index, day -> UsageRow(days[index].take(3), day) }
    } else {
        hourly(selected ?: 6)
    }
    val usage = apps.map { it to values.getValue(it.category) * it.share }.sortedByDescending { it.second }.take(5)
    val longest = usage.maxOf { it.second }
    val comparison = if (selected == null) {
        "8% less than last week"
    } else {
        "${(abs(headline / average - 1) * 100).roundToInt()}% ${if (headline > average) "above" else "below"} average"
    }
    val swatch = { key: String -> chartConfig[key]?.color?.invoke(colors) ?: colors.mutedForeground }
    val value = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum")
    Card(modifier, variant = CardVariant.Filled, size = CardSize.Sm) {
        CardContent {
            Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
                Column(
                    Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                    verticalArrangement = Arrangement.spacedBy(2.dp),
                ) {
                    BasicText(
                        selected?.let { days[it] } ?: "Daily average",
                        style = Typography.sm.copy(color = colors.mutedForeground, fontWeight = FontWeight.SemiBold),
                    )
                    BasicText(
                        formatMinutes(headline),
                        style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                    )
                    BasicText(comparison, style = Typography.sm.copy(color = colors.mutedForeground))
                }
                ToggleGroup(
                    value = view,
                    onValueChange = { next ->
                        if (next != null) {
                            view = next
                            hour = null
                            if (next == "day" && selected == null) selected = 6
                        }
                    },
                    size = ToggleSize.Sm,
                    spacing = 0,
                ) {
                    ToggleGroupItem("Week", "week", Modifier.padding(horizontal = 8.dp))
                    ToggleGroupItem("Day", "day", Modifier.padding(horizontal = 8.dp))
                }
            }
        }
        CardContent {
            Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
                ChartContainer(
                    chartConfig,
                    Modifier
                        .fillMaxWidth()
                        .height(256.dp),
                ) {
                    BarChart(
                        data,
                        margin = ChartMargin(top = 10.dp, right = 4.dp, bottom = 0.dp, left = 12.dp),
                        accessibilityLabel = if (isWeek) "Screen time this week" else "Screen time by hour",
                        selectedIndex = if (isWeek) selected else hour,
                        onSelectedIndexChange = { index -> if (isWeek) selected = index else hour = index },
                    ) {
                        CartesianGrid(strokeDasharray = listOf(2f, 4f))
                        if (isWeek) {
                            XAxis(label = { it.label }, tickMargin = 8.dp, interval = 0)
                            YAxis(
                                orientation = ChartAxisOrientation.Right,
                                tickMargin = 6.dp,
                                ticks = listOf(0.0, 120.0, 240.0, 360.0),
                                tickFormatter = { "${(it / 60).toInt()}h" },
                            )
                            ReferenceLine(average, stroke = Palette.green500, strokeDasharray = listOf(3f, 3f))
                        } else {
                            XAxis(label = { it.label }, tickMargin = 8.dp, interval = 0, ticks = listOf(0, 6, 12, 18), tickFormatter = { "$it:00" })
                            YAxis(orientation = ChartAxisOrientation.Right, tickMargin = 6.dp, tickCount = 4, tickFormatter = { "${it.toInt()}m" })
                        }
                        ChartTooltip(cursor = null) {
                            ChartTooltipContent(
                                labelFormatter = { label, _ ->
                                    BasicText(
                                        if (isWeek) days.first { it.startsWith(label) } else "$label:00 – ${label.toInt() + 1}:00",
                                        style = Typography.xs.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                                    )
                                },
                                formatter = { item ->
                                    Row(
                                        Modifier.fillMaxWidth(),
                                        horizontalArrangement = Arrangement.spacedBy(8.dp),
                                        verticalAlignment = Alignment.CenterVertically,
                                    ) {
                                        Box(
                                            Modifier
                                                .size(8.dp)
                                                .background(item.color, RoundedCornerShape(Radius.xs)),
                                        )
                                        BasicText(
                                            chartConfig[item.key]?.label ?: item.key,
                                            Modifier.weight(1f),
                                            style = Typography.xs.copy(color = colors.mutedForeground),
                                        )
                                        BasicText(
                                            formatMinutes(item.value),
                                            style = Typography.xs.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                                        )
                                    }
                                },
                            )
                        }
                        categories.forEachIndexed { position, category ->
                            Bar(
                                category,
                                value = { it.values.getValue(category) },
                                stackId = "a",
                                radius = if (position == categories.size - 1) ChartRadius(5.dp, 5.dp, 0.dp, 0.dp) else ChartRadius(0.dp),
                                maxBarSize = if (isWeek) 34.dp else 10.dp,
                                cells = if (isWeek) {
                                    { index -> ChartCell(alpha = if (selected == null || selected == index) 1f else 0.3f) }
                                } else {
                                    null
                                },
                                isAnimationActive = false,
                            )
                        }
                    }
                }
                Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                    for (category in categories) {
                        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
                            Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
                                Box(
                                    Modifier
                                        .size(8.dp)
                                        .background(swatch(category), CircleShape),
                                )
                                BasicText(
                                    chartConfig[category]?.label ?: category,
                                    style = Typography.sm.copy(color = colors.mutedForeground),
                                    maxLines = 1,
                                    overflow = TextOverflow.Ellipsis,
                                )
                            }
                            BasicText(formatMinutes(values.getValue(category)), style = value)
                        }
                    }
                }
                Column {
                    BasicText(
                        "MOST USED",
                        Modifier.padding(bottom = 4.dp),
                        style = Typography.xs.copy(color = colors.mutedForeground, fontWeight = FontWeight.SemiBold),
                    )
                    for ((app, minutes) in usage) {
                        Separator()
                        val fraction by animateFloatAsState((minutes / longest).toFloat(), label = "fraction")
                        Row(
                            Modifier
                                .fillMaxWidth()
                                .padding(vertical = 8.dp),
                            horizontalArrangement = Arrangement.spacedBy(12.dp),
                            verticalAlignment = Alignment.CenterVertically,
                        ) {
                            BasicText(
                                app.name,
                                Modifier.width(96.dp),
                                style = Typography.base.copy(color = colors.foreground),
                                maxLines = 1,
                                overflow = TextOverflow.Ellipsis,
                            )
                            Box(
                                Modifier
                                    .weight(1f)
                                    .height(6.dp)
                                    .background(colors.secondary, CircleShape),
                            ) {
                                Box(
                                    Modifier
                                        .fillMaxWidth(fraction)
                                        .fillMaxHeight()
                                        .background(swatch(app.category), CircleShape),
                                )
                            }
                            BasicText(
                                formatMinutes(minutes),
                                Modifier.width(72.dp),
                                style = Typography.sm.copy(color = colors.mutedForeground, fontFeatureSettings = "tnum", textAlign = TextAlign.End),
                            )
                        }
                    }
                }
            }
        }
    }
}
```

Bars stack social, productivity and entertainment time. A `ToggleGroup` switches between a weekly view and an hourly view of one day. In the week view, touching a bar selects that day through `onActiveIndexChange`, dims the other days and updates the headline, comparison text, category totals and the most used apps list below. A dashed line marks the weekly average. The bars set `isAnimationActive={false}`, so they draw without the enter animation and switch views instantly.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/bar-chart-interactive
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add bar-chart-interactive
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add bar-chart-interactive --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add bar-chart-interactive --platform kotlin
```

The CLI also adds what it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/charts/bar-chart-interactive.tsx`:

- `uniwind/components/charts/bar-chart-interactive.tsx`: shown above

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/charts/bar-chart-interactive.tsx`:

```tsx title="nativewind/components/charts/bar-chart-interactive.tsx"
import { cn } from "cn"
import * as React from "react"
import { View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  Bar,
  BarChart,
  CartesianGrid,
  Cell,
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  ReferenceLine,
  XAxis,
  YAxis,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Category = "social" | "productivity" | "entertainment"

type Period = "week" | "day"

const CATEGORIES: Category[] = ["social", "productivity", "entertainment"]

const DAYS = [
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
  "Sunday",
]

const USAGE: [social: number, productivity: number, entertainment: number][] = [
  [48, 95, 62],
  [55, 120, 40],
  [70, 88, 95],
  [42, 130, 35],
  [65, 100, 80],
  [110, 30, 140],
  [95, 25, 120],
]

const WEEK: Record<Category, number>[] = USAGE.map(
  ([social, productivity, entertainment]) => ({
    social,
    productivity,
    entertainment,
  })
)

const PROFILE = [
  1, 0, 0, 0, 0, 0, 1, 4, 6, 5, 4, 5, 7, 6, 5, 4, 5, 6, 7, 9, 10, 11, 8, 4,
]

const APPS: { name: string; category: Category; share: number }[] = [
  { name: "Chat", category: "social", share: 0.55 },
  { name: "Messages", category: "social", share: 0.45 },
  { name: "Browser", category: "productivity", share: 0.45 },
  { name: "Mail", category: "productivity", share: 0.3 },
  { name: "Docs", category: "productivity", share: 0.25 },
  { name: "Video", category: "entertainment", share: 0.6 },
  { name: "Music", category: "entertainment", share: 0.4 },
]

const chartConfig = {
  social: { label: "Social", color: "chart-1" },
  productivity: { label: "Productivity", color: "cyan-500" },
  entertainment: { label: "Entertainment", color: "chart-4" },
} satisfies ChartConfig

const SWATCH: Record<Category, string> = {
  social: "bg-blue-500",
  productivity: "bg-cyan-500",
  entertainment: "bg-orange-500",
}

const total = (day: Record<Category, number>) =>
  CATEGORIES.reduce((sum, category) => sum + day[category], 0)

const AVERAGE = WEEK.reduce((sum, day) => sum + total(day), 0) / WEEK.length

const DAILY_AVERAGE = Object.fromEntries(
  CATEGORIES.map((category) => [
    category,
    WEEK.reduce((sum, day) => sum + day[category], 0) / WEEK.length,
  ])
) as Record<Category, number>

function formatMinutes(minutes: number) {
  const rounded = Math.round(minutes)
  const hours = Math.floor(rounded / 60)
  return hours ? `${hours}h ${rounded % 60}m` : `${rounded}m`
}

function noise(seed: number) {
  const state = (seed * 16807) % 2147483647
  return (state - 1) / 2147483646
}

function hourly(day: number) {
  const profileTotal = PROFILE.reduce((sum, value) => sum + value, 0)
  const categories = WEEK[day]
  if (!categories) return []
  return PROFILE.map((weight, hour) => {
    const share =
      (weight / profileTotal) * (0.75 + noise(hour + 7 * (day + 1)) * 0.5)
    return {
      label: String(hour),
      index: hour,
      social: Math.round(categories.social * share),
      productivity: Math.round(
        categories.productivity * share * (hour > 8 && hour < 18 ? 1.6 : 0.4)
      ),
      entertainment: Math.round(
        categories.entertainment * share * (hour > 18 ? 1.8 : 0.5)
      ),
    }
  })
}

export function BarChartInteractive() {
  const [view, setView] = React.useState<Period>("week")
  const [selected, setSelected] = React.useState<number | null>(null)

  const isWeek = view === "week"
  const selectedDay = selected === null ? undefined : WEEK[selected]
  const categories = selectedDay ?? DAILY_AVERAGE
  const headline = selectedDay ? total(selectedDay) : AVERAGE
  const data = isWeek
    ? WEEK.map((day, index) => ({
        ...day,
        label: DAYS[index]?.slice(0, 3) ?? "",
        index,
      }))
    : hourly(selected ?? 6)
  const apps = APPS.map((app) => ({
    ...app,
    minutes: categories[app.category] * app.share,
  }))
    .sort((a, b) => b.minutes - a.minutes)
    .slice(0, 5)
  const longest = Math.max(...apps.map((app) => app.minutes))
  const comparison =
    selected === null
      ? "8% less than last week"
      : `${Math.round(Math.abs(headline / AVERAGE - 1) * 100)}% ${headline > AVERAGE ? "above" : "below"} average`

  return (
    <Card variant="filled" size="sm">
      <CardHeader className="flex-row flex-wrap items-start justify-between gap-4">
        <View
          className="min-w-52 grow gap-0.5"
          accessibilityLiveRegion="polite"
        >
          <Text className="text-sm font-semibold text-muted-foreground">
            {selected === null ? "Daily average" : DAYS[selected]}
          </Text>
          <Text className="text-3xl font-semibold tracking-tight tabular-nums">
            {formatMinutes(headline)}
          </Text>
          <Text className="text-sm text-muted-foreground">{comparison}</Text>
        </View>
        <ToggleGroup
          spacing={0}
          value={[view]}
          onValueChange={(value) => {
            const next = value[0] as Period | undefined
            if (!next) return
            setView(next)
            if (next === "day" && selected === null) setSelected(6)
          }}
          accessibilityLabel="Period"
        >
          <ToggleGroupItem value="week" size="sm" className="px-5">
            <Text>Week</Text>
          </ToggleGroupItem>
          <ToggleGroupItem value="day" size="sm" className="px-5">
            <Text>Day</Text>
          </ToggleGroupItem>
        </ToggleGroup>
      </CardHeader>
      <CardContent className="gap-5">
        <ChartContainer
          config={chartConfig}
          accessibilityLabel="Screen time"
          className="h-64 w-full"
        >
          <BarChart
            key={view}
            data={data}
            margin={{ top: 10, right: 4, left: 12, bottom: 0 }}
            onActiveIndexChange={isWeek ? setSelected : undefined}
          >
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="label"
              tickMargin={8}
              interval={0}
              ticks={isWeek ? undefined : ["0", "6", "12", "18"]}
              tickFormatter={(value) => (isWeek ? `${value}` : `${value}:00`)}
            />
            <YAxis
              orientation="right"
              tickMargin={6}
              width="auto"
              ticks={isWeek ? [0, 120, 240, 360] : undefined}
              tickCount={isWeek ? undefined : 4}
              tickFormatter={(value) =>
                isWeek ? `${Number(value) / 60}h` : `${value}m`
              }
            />
            <ChartTooltip
              cursor={false}
              content={
                <ChartTooltipContent
                  labelFormatter={(_, payload) => {
                    const label = String(payload[0]?.payload.label ?? "")
                    return isWeek
                      ? DAYS.find((day) => day.startsWith(label))
                      : `${label}:00 – ${Number(label) + 1}:00`
                  }}
                  formatter={(value, name) => (
                    <View className="w-full flex-row items-center gap-2">
                      <View
                        className={cn(
                          "size-2 shrink-0 rounded-xs",
                          SWATCH[name as Category]
                        )}
                      />
                      <Text className="grow text-muted-foreground">
                        {chartConfig[name as Category].label}
                      </Text>
                      <Text className="font-semibold tabular-nums">
                        {formatMinutes(Number(value))}
                      </Text>
                    </View>
                  )}
                />
              }
            />
            {isWeek ? (
              <ReferenceLine
                y={AVERAGE}
                stroke="green-500"
                strokeDasharray="3 3"
              />
            ) : null}
            {CATEGORIES.map((category, position) => (
              <Bar
                isAnimationActive={false}
                key={category}
                dataKey={category}
                stackId="a"
                fill={chartConfig[category].color}
                radius={position === CATEGORIES.length - 1 ? [5, 5, 0, 0] : 0}
                maxBarSize={isWeek ? 34 : 10}
              >
                {isWeek
                  ? data.map((item, index) => (
                      <Cell
                        key={item.label}
                        fillOpacity={
                          selected === null || selected === index ? 1 : 0.3
                        }
                      />
                    ))
                  : null}
              </Bar>
            ))}
          </BarChart>
        </ChartContainer>
        <View className="flex-row gap-3">
          {CATEGORIES.map((category) => (
            <View key={category} accessible className="min-w-0 flex-1 gap-0.5">
              <View className="min-w-0 flex-row items-center gap-1.5">
                <View
                  className={cn(
                    "size-2 shrink-0 rounded-full",
                    SWATCH[category]
                  )}
                />
                <Text
                  numberOfLines={1}
                  className="shrink text-sm text-muted-foreground"
                >
                  {chartConfig[category].label}
                </Text>
              </View>
              <Text className="text-base font-semibold tabular-nums">
                {formatMinutes(categories[category])}
              </Text>
            </View>
          ))}
        </View>
        <View>
          <Text className="mb-1 text-xs font-semibold text-muted-foreground uppercase">
            Most used
          </Text>
          {apps.map((app) => (
            <View
              key={app.name}
              accessible
              className="flex-row items-center gap-3 border-t border-border py-2"
            >
              <Text numberOfLines={1} className="w-24 text-base">
                {app.name}
              </Text>
              <View className="h-1.5 grow overflow-hidden rounded-full bg-secondary">
                <View
                  style={{ width: `${(app.minutes / longest) * 100}%` }}
                  className={cn("h-full rounded-full", SWATCH[app.category])}
                />
              </View>
              <Text className="w-[4.5rem] text-right text-sm text-muted-foreground tabular-nums">
                {formatMinutes(app.minutes)}
              </Text>
            </View>
          ))}
        </View>
      </CardContent>
    </Card>
  )
}
```

4. Update the import paths to match your project.

**StyleSheet**

1. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

2. Copy the source into `components/charts/bar-chart-interactive.tsx`:

```tsx title="stylesheet/components/charts/bar-chart-interactive.tsx"
import * as React from "react"
import { StyleSheet, View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  Bar,
  BarChart,
  CartesianGrid,
  Cell,
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  ReferenceLine,
  XAxis,
  YAxis,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

type Category = "social" | "productivity" | "entertainment"

type Period = "week" | "day"

const CATEGORIES: Category[] = ["social", "productivity", "entertainment"]

const DAYS = [
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
  "Sunday",
]

const USAGE: [social: number, productivity: number, entertainment: number][] = [
  [48, 95, 62],
  [55, 120, 40],
  [70, 88, 95],
  [42, 130, 35],
  [65, 100, 80],
  [110, 30, 140],
  [95, 25, 120],
]

const WEEK: Record<Category, number>[] = USAGE.map(
  ([social, productivity, entertainment]) => ({
    social,
    productivity,
    entertainment,
  })
)

const PROFILE = [
  1, 0, 0, 0, 0, 0, 1, 4, 6, 5, 4, 5, 7, 6, 5, 4, 5, 6, 7, 9, 10, 11, 8, 4,
]

const APPS: { name: string; category: Category; share: number }[] = [
  { name: "Chat", category: "social", share: 0.55 },
  { name: "Messages", category: "social", share: 0.45 },
  { name: "Browser", category: "productivity", share: 0.45 },
  { name: "Mail", category: "productivity", share: 0.3 },
  { name: "Docs", category: "productivity", share: 0.25 },
  { name: "Video", category: "entertainment", share: 0.6 },
  { name: "Music", category: "entertainment", share: 0.4 },
]

const chartConfig = {
  social: { label: "Social", color: "chart-1" },
  productivity: { label: "Productivity", color: "cyan-500" },
  entertainment: { label: "Entertainment", color: "chart-4" },
} satisfies ChartConfig

const SWATCH: Record<Category, Tone> = {
  social: "chart1",
  productivity: "cyan500",
  entertainment: "chart4",
}

const styles = StyleSheet.create({
  header: {
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "flex-start",
    justifyContent: "space-between",
    gap: 16,
  },
  heading: { minWidth: 208, flexGrow: 1, gap: 2 },
  eyebrow: { ...fontSize.sm, fontWeight: "600" },
  value: {
    ...fontSize["3xl"],
    fontWeight: "600",
    letterSpacing: -0.75,
    fontVariant: ["tabular-nums"],
  },
  caption: { ...fontSize.sm },
  period: { paddingHorizontal: 20 },
  content: { gap: 20 },
  chart: { height: 256 },
  row: { width: "100%", flexDirection: "row", alignItems: "center", gap: 8 },
  square: { width: 8, height: 8, flexShrink: 0, borderRadius: 2 },
  grow: { flexGrow: 1 },
  strong: { fontWeight: "600", fontVariant: ["tabular-nums"] },
  stats: { flexDirection: "row", gap: 12 },
  stat: { minWidth: 0, flex: 1, gap: 2 },
  statLabel: {
    minWidth: 0,
    flexDirection: "row",
    alignItems: "center",
    gap: 6,
  },
  dot: { width: 8, height: 8, flexShrink: 0, borderRadius: 9999 },
  shrink: { flexShrink: 1, ...fontSize.sm },
  statValue: {
    ...fontSize.base,
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  section: {
    marginBottom: 4,
    ...fontSize.xs,
    fontWeight: "600",
    textTransform: "uppercase",
  },
  app: {
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    borderTopWidth: StyleSheet.hairlineWidth,
    paddingVertical: 8,
  },
  appName: { width: 96, ...fontSize.base },
  track: {
    height: 6,
    flexGrow: 1,
    overflow: "hidden",
    borderRadius: 9999,
  },
  fill: { height: "100%", borderRadius: 9999 },
  time: {
    width: 72,
    textAlign: "right",
    ...fontSize.sm,
    fontVariant: ["tabular-nums"],
  },
})

const total = (day: Record<Category, number>) =>
  CATEGORIES.reduce((sum, category) => sum + day[category], 0)

const AVERAGE = WEEK.reduce((sum, day) => sum + total(day), 0) / WEEK.length

const DAILY_AVERAGE = Object.fromEntries(
  CATEGORIES.map((category) => [
    category,
    WEEK.reduce((sum, day) => sum + day[category], 0) / WEEK.length,
  ])
) as Record<Category, number>

function formatMinutes(minutes: number) {
  const rounded = Math.round(minutes)
  const hours = Math.floor(rounded / 60)
  return hours ? `${hours}h ${rounded % 60}m` : `${rounded}m`
}

function noise(seed: number) {
  const state = (seed * 16807) % 2147483647
  return (state - 1) / 2147483646
}

function hourly(day: number) {
  const profileTotal = PROFILE.reduce((sum, value) => sum + value, 0)
  const categories = WEEK[day]
  if (!categories) return []
  return PROFILE.map((weight, hour) => {
    const share =
      (weight / profileTotal) * (0.75 + noise(hour + 7 * (day + 1)) * 0.5)
    return {
      label: String(hour),
      index: hour,
      social: Math.round(categories.social * share),
      productivity: Math.round(
        categories.productivity * share * (hour > 8 && hour < 18 ? 1.6 : 0.4)
      ),
      entertainment: Math.round(
        categories.entertainment * share * (hour > 18 ? 1.8 : 0.5)
      ),
    }
  })
}

export function BarChartInteractive() {
  const { colors } = useTheme()
  const [view, setView] = React.useState<Period>("week")
  const [selected, setSelected] = React.useState<number | null>(null)

  const isWeek = view === "week"
  const selectedDay = selected === null ? undefined : WEEK[selected]
  const categories = selectedDay ?? DAILY_AVERAGE
  const headline = selectedDay ? total(selectedDay) : AVERAGE
  const data = isWeek
    ? WEEK.map((day, index) => ({
        ...day,
        label: DAYS[index]?.slice(0, 3) ?? "",
        index,
      }))
    : hourly(selected ?? 6)
  const apps = APPS.map((app) => ({
    ...app,
    minutes: categories[app.category] * app.share,
  }))
    .sort((a, b) => b.minutes - a.minutes)
    .slice(0, 5)
  const longest = Math.max(...apps.map((app) => app.minutes))
  const comparison =
    selected === null
      ? "8% less than last week"
      : `${Math.round(Math.abs(headline / AVERAGE - 1) * 100)}% ${headline > AVERAGE ? "above" : "below"} average`

  return (
    <Card variant="filled" size="sm">
      <CardHeader style={styles.header}>
        <View style={styles.heading} accessibilityLiveRegion="polite">
          <Text style={[styles.eyebrow, { color: colors.mutedForeground }]}>
            {selected === null ? "Daily average" : DAYS[selected]}
          </Text>
          <Text style={styles.value}>{formatMinutes(headline)}</Text>
          <Text style={[styles.caption, { color: colors.mutedForeground }]}>
            {comparison}
          </Text>
        </View>
        <ToggleGroup
          spacing={0}
          value={[view]}
          onValueChange={(value) => {
            const next = value[0] as Period | undefined
            if (!next) return
            setView(next)
            if (next === "day" && selected === null) setSelected(6)
          }}
          accessibilityLabel="Period"
        >
          <ToggleGroupItem value="week" size="sm" style={styles.period}>
            <Text>Week</Text>
          </ToggleGroupItem>
          <ToggleGroupItem value="day" size="sm" style={styles.period}>
            <Text>Day</Text>
          </ToggleGroupItem>
        </ToggleGroup>
      </CardHeader>
      <CardContent style={styles.content}>
        <ChartContainer
          config={chartConfig}
          accessibilityLabel="Screen time"
          style={styles.chart}
        >
          <BarChart
            key={view}
            data={data}
            margin={{ top: 10, right: 4, left: 12, bottom: 0 }}
            onActiveIndexChange={isWeek ? setSelected : undefined}
          >
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="label"
              tickMargin={8}
              interval={0}
              ticks={isWeek ? undefined : ["0", "6", "12", "18"]}
              tickFormatter={(value) => (isWeek ? `${value}` : `${value}:00`)}
            />
            <YAxis
              orientation="right"
              tickMargin={6}
              width="auto"
              ticks={isWeek ? [0, 120, 240, 360] : undefined}
              tickCount={isWeek ? undefined : 4}
              tickFormatter={(value) =>
                isWeek ? `${Number(value) / 60}h` : `${value}m`
              }
            />
            <ChartTooltip
              cursor={false}
              content={
                <ChartTooltipContent
                  labelFormatter={(_, payload) => {
                    const label = String(payload[0]?.payload.label ?? "")
                    return isWeek
                      ? DAYS.find((day) => day.startsWith(label))
                      : `${label}:00 – ${Number(label) + 1}:00`
                  }}
                  formatter={(value, name) => (
                    <View style={styles.row}>
                      <View
                        style={[
                          styles.square,
                          {
                            backgroundColor: tone(
                              colors,
                              SWATCH[name as Category]
                            ),
                          },
                        ]}
                      />
                      <Text
                        style={[styles.grow, { color: colors.mutedForeground }]}
                      >
                        {chartConfig[name as Category].label}
                      </Text>
                      <Text style={styles.strong}>
                        {formatMinutes(Number(value))}
                      </Text>
                    </View>
                  )}
                />
              }
            />
            {isWeek ? (
              <ReferenceLine
                y={AVERAGE}
                stroke="green-500"
                strokeDasharray="3 3"
              />
            ) : null}
            {CATEGORIES.map((category, position) => (
              <Bar
                isAnimationActive={false}
                key={category}
                dataKey={category}
                stackId="a"
                fill={chartConfig[category].color}
                radius={position === CATEGORIES.length - 1 ? [5, 5, 0, 0] : 0}
                maxBarSize={isWeek ? 34 : 10}
              >
                {isWeek
                  ? data.map((item, index) => (
                      <Cell
                        key={item.label}
                        fillOpacity={
                          selected === null || selected === index ? 1 : 0.3
                        }
                      />
                    ))
                  : null}
              </Bar>
            ))}
          </BarChart>
        </ChartContainer>
        <View style={styles.stats}>
          {CATEGORIES.map((category) => (
            <View key={category} accessible style={styles.stat}>
              <View style={styles.statLabel}>
                <View
                  style={[
                    styles.dot,
                    { backgroundColor: tone(colors, SWATCH[category]) },
                  ]}
                />
                <Text
                  numberOfLines={1}
                  style={[styles.shrink, { color: colors.mutedForeground }]}
                >
                  {chartConfig[category].label}
                </Text>
              </View>
              <Text style={styles.statValue}>
                {formatMinutes(categories[category])}
              </Text>
            </View>
          ))}
        </View>
        <View>
          <Text style={[styles.section, { color: colors.mutedForeground }]}>
            Most used
          </Text>
          {apps.map((app) => (
            <View
              key={app.name}
              accessible
              style={[styles.app, { borderTopColor: colors.border }]}
            >
              <Text numberOfLines={1} style={styles.appName}>
                {app.name}
              </Text>
              <View
                style={[styles.track, { backgroundColor: colors.secondary }]}
              >
                <View
                  style={[
                    styles.fill,
                    {
                      width: `${(app.minutes / longest) * 100}%`,
                      backgroundColor: tone(colors, SWATCH[app.category]),
                    },
                  ]}
                />
              </View>
              <Text style={[styles.time, { color: colors.mutedForeground }]}>
                {formatMinutes(app.minutes)}
              </Text>
            </View>
          ))}
        </View>
      </CardContent>
    </Card>
  )
}
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Charts/BarChartInteractive.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Card.swift`: see [Card](https://ui.suiss.app/docs/components/card.md)

- `swift/Components/Chart.swift`: see [Chart](https://ui.suiss.app/docs/components/chart.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Components/Toggle.swift`: see [Toggle](https://ui.suiss.app/docs/components/toggle.md)

- `swift/Components/ToggleGroup.swift`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/charts/BarChartInteractive.kt`: shown above

- `kotlin/components/Card.kt`: see [Card](https://ui.suiss.app/docs/components/card.md)

- `kotlin/components/Chart.kt`: see [Chart](https://ui.suiss.app/docs/components/chart.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/Toggle.kt`: see [Toggle](https://ui.suiss.app/docs/components/toggle.md)

- `kotlin/components/ToggleGroup.kt`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { BarChartInteractive } from "@/components/charts/bar-chart-interactive"
```

```tsx
<BarChartInteractive />
```

Replace `USAGE` with daily minutes per category and `APPS` with your apps and their share of each category. The hourly view is generated from `PROFILE`, so swap `hourly()` for real data. The component takes no props.

