FLOWDocs
Components

Data display

Table, Pagination, Carousel, Chart, Avatar, Item, ProfilePill.

Table

Semantic table wrappers. Exports: Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption.

View source on GitHub →

Usage:

import {
  Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption,
} from "@flow-industries/ui/components/table";

<Table>
  <TableCaption>Recent deploys</TableCaption>
  <TableHeader>
    <TableRow>
      <TableHead>App</TableHead>
      <TableHead>Server</TableHead>
      <TableHead className="text-right">Uptime</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell>site</TableCell>
      <TableCell>fl-1</TableCell>
      <TableCell className="text-right">3d 4h</TableCell>
    </TableRow>
  </TableBody>
</Table>

Source:

components/ui/table.tsx
"use client";

import type * as React from "react";

import { cn } from "../../utils/cn";

function Table({ className, ...props }: React.ComponentProps<"table">) {
  return (
    <div
      data-slot="table-container"
      className="relative w-full overflow-x-auto"
    >
      <table
        data-slot="table"
        className={cn("w-full caption-bottom text-sm", className)}
        {...props}
      />
    </div>
  );
}

function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
  return (
    <thead
      data-slot="table-header"
      className={cn(
        "[&_tr]:border-b-[length:var(--border-width)] [&_tr]:hover:bg-transparent",
        className,
      )}
      {...props}
    />
  );
}

function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
  return (
    <tbody
      data-slot="table-body"
      className={cn("[&_tr:last-child]:border-0", className)}
      {...props}
    />
  );
}

function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
  return (
    <tfoot
      data-slot="table-footer"
      className={cn(
        "border-t-[length:var(--border-width)] bg-muted/50 font-medium [&>tr]:last:border-b-0",
        className,
      )}
      {...props}
    />
  );
}

function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
  return (
    <tr
      data-slot="table-row"
      className={cn(
        "border-b-[length:var(--border-width)] transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
        className,
      )}
      {...props}
    />
  );
}

function TableHead({ className, ...props }: React.ComponentProps<"th">) {
  return (
    <th
      data-slot="table-head"
      className={cn(
        "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
        className,
      )}
      {...props}
    />
  );
}

function TableCell({ className, ...props }: React.ComponentProps<"td">) {
  return (
    <td
      data-slot="table-cell"
      className={cn(
        "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
        className,
      )}
      {...props}
    />
  );
}

function TableCaption({
  className,
  ...props
}: React.ComponentProps<"caption">) {
  return (
    <caption
      data-slot="table-caption"
      className={cn("mt-4 text-sm text-muted-foreground", className)}
      {...props}
    />
  );
}

export {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
};

Pagination

Exports: Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious.

View source on GitHub →

Usage:

import {
  Pagination, PaginationContent, PaginationItem,
  PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
} from "@flow-industries/ui/components/pagination";

<Pagination>
  <PaginationContent>
    <PaginationItem><PaginationPrevious href="#" /></PaginationItem>
    <PaginationItem><PaginationLink href="#">1</PaginationLink></PaginationItem>
    <PaginationItem><PaginationLink href="#" isActive>2</PaginationLink></PaginationItem>
    <PaginationItem><PaginationLink href="#">3</PaginationLink></PaginationItem>
    <PaginationItem><PaginationEllipsis /></PaginationItem>
    <PaginationItem><PaginationNext href="#" /></PaginationItem>
  </PaginationContent>
</Pagination>

Source:

components/ui/pagination.tsx
import {
  ChevronLeftIcon,
  ChevronRightIcon,
  MoreHorizontalIcon,
} from "lucide-react";
import type * as React from "react";
import { cn } from "../../utils/cn";
import { Button } from "./button";

function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
  return (
    <nav
      aria-label="pagination"
      data-slot="pagination"
      className={cn("mx-auto flex w-full justify-center", className)}
      {...props}
    />
  );
}

function PaginationContent({
  className,
  ...props
}: React.ComponentProps<"ul">) {
  return (
    <ul
      data-slot="pagination-content"
      className={cn("flex items-center gap-0.5", className)}
      {...props}
    />
  );
}

function PaginationItem({ ...props }: React.ComponentProps<"li">) {
  return <li data-slot="pagination-item" {...props} />;
}

type PaginationLinkProps = {
  isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
  React.ComponentProps<"a">;

function PaginationLink({
  className,
  isActive,
  size = "icon",
  ...props
}: PaginationLinkProps) {
  return (
    <Button
      variant={isActive ? "outline" : "ghost"}
      size={size}
      className={cn(className)}
      nativeButton={false}
      render={
        <a
          aria-current={isActive ? "page" : undefined}
          data-slot="pagination-link"
          data-active={isActive}
          {...props}
        />
      }
    />
  );
}

function PaginationPrevious({
  className,
  text = "Previous",
  ...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
  return (
    <PaginationLink
      aria-label="Go to previous page"
      size="default"
      className={cn("pl-1.5!", className)}
      {...props}
    >
      <ChevronLeftIcon data-icon="inline-start" />
      <span className="hidden sm:block">{text}</span>
    </PaginationLink>
  );
}

function PaginationNext({
  className,
  text = "Next",
  ...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
  return (
    <PaginationLink
      aria-label="Go to next page"
      size="default"
      className={cn("pr-1.5!", className)}
      {...props}
    >
      <span className="hidden sm:block">{text}</span>
      <ChevronRightIcon data-icon="inline-end" />
    </PaginationLink>
  );
}

function PaginationEllipsis({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      aria-hidden
      data-slot="pagination-ellipsis"
      className={cn(
        "flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
        className,
      )}
      {...props}
    >
      <MoreHorizontalIcon />
      <span className="sr-only">More pages</span>
    </span>
  );
}

export {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
};

Embla-powered image carousel with keyboard + swipe support. Exports: Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel, CarouselApi type.

View source on GitHub →

Usage:

import {
  Carousel, CarouselContent, CarouselItem,
  CarouselPrevious, CarouselNext,
} from "@flow-industries/ui/components/carousel";

<Carousel>
  <CarouselContent>
    <CarouselItem><img src="/1.jpg" /></CarouselItem>
    <CarouselItem><img src="/2.jpg" /></CarouselItem>
    <CarouselItem><img src="/3.jpg" /></CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

Source:

components/ui/carousel.tsx
"use client";

import useEmblaCarousel, {
  type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import * as React from "react";
import { cn } from "../../utils/cn";
import { Button } from "./button";

type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];

type CarouselProps = {
  opts?: CarouselOptions;
  plugins?: CarouselPlugin;
  orientation?: "horizontal" | "vertical";
  setApi?: (api: CarouselApi) => void;
};

type CarouselContextProps = {
  carouselRef: ReturnType<typeof useEmblaCarousel>[0];
  api: ReturnType<typeof useEmblaCarousel>[1];
  scrollPrev: () => void;
  scrollNext: () => void;
  canScrollPrev: boolean;
  canScrollNext: boolean;
} & CarouselProps;

const CarouselContext = React.createContext<CarouselContextProps | null>(null);

function useCarousel() {
  const context = React.useContext(CarouselContext);

  if (!context) {
    throw new Error("useCarousel must be used within a <Carousel />");
  }

  return context;
}

function Carousel({
  orientation = "horizontal",
  opts,
  setApi,
  plugins,
  className,
  children,
  ...props
}: React.ComponentProps<"div"> & CarouselProps) {
  const [carouselRef, api] = useEmblaCarousel(
    {
      ...opts,
      axis: orientation === "horizontal" ? "x" : "y",
    },
    plugins,
  );
  const [canScrollPrev, setCanScrollPrev] = React.useState(false);
  const [canScrollNext, setCanScrollNext] = React.useState(false);

  const onSelect = React.useCallback((api: CarouselApi) => {
    if (!api) return;
    setCanScrollPrev(api.canScrollPrev());
    setCanScrollNext(api.canScrollNext());
  }, []);

  const scrollPrev = React.useCallback(() => {
    api?.scrollPrev();
  }, [api]);

  const scrollNext = React.useCallback(() => {
    api?.scrollNext();
  }, [api]);

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      if (event.key === "ArrowLeft") {
        event.preventDefault();
        scrollPrev();
      } else if (event.key === "ArrowRight") {
        event.preventDefault();
        scrollNext();
      }
    },
    [scrollPrev, scrollNext],
  );

  React.useEffect(() => {
    if (!api || !setApi) return;
    setApi(api);
  }, [api, setApi]);

  React.useEffect(() => {
    if (!api) return;
    onSelect(api);
    api.on("reInit", onSelect);
    api.on("select", onSelect);

    return () => {
      api?.off("select", onSelect);
    };
  }, [api, onSelect]);

  return (
    <CarouselContext.Provider
      value={{
        carouselRef,
        api: api,
        opts,
        orientation:
          orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
        scrollPrev,
        scrollNext,
        canScrollPrev,
        canScrollNext,
      }}
    >
      {/* biome-ignore lint/a11y/useSemanticElements: carousel region with aria-roledescription is the accepted embla/shadcn pattern */}
      <div
        onKeyDownCapture={handleKeyDown}
        className={cn("relative", className)}
        role="region"
        aria-roledescription="carousel"
        data-slot="carousel"
        {...props}
      >
        {children}
      </div>
    </CarouselContext.Provider>
  );
}

function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
  const { carouselRef, orientation } = useCarousel();

  return (
    <div
      ref={carouselRef}
      className="overflow-hidden"
      data-slot="carousel-content"
    >
      <div
        className={cn(
          "flex",
          orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
          className,
        )}
        {...props}
      />
    </div>
  );
}

function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
  const { orientation } = useCarousel();

  return (
    // biome-ignore lint/a11y/useSemanticElements: slide group with aria-roledescription is the accepted embla/shadcn pattern
    <div
      role="group"
      aria-roledescription="slide"
      data-slot="carousel-item"
      className={cn(
        "min-w-0 shrink-0 grow-0 basis-full",
        orientation === "horizontal" ? "pl-4" : "pt-4",
        className,
      )}
      {...props}
    />
  );
}

function CarouselPrevious({
  className,
  variant = "outline",
  size = "icon-sm",
  ...props
}: React.ComponentProps<typeof Button>) {
  const { orientation, scrollPrev, canScrollPrev } = useCarousel();

  return (
    <Button
      data-slot="carousel-previous"
      variant={variant}
      size={size}
      className={cn(
        "absolute touch-manipulation rounded-full",
        orientation === "horizontal"
          ? "top-1/2 -left-12 -translate-y-1/2 active:-translate-y-1/2!"
          : "-top-12 left-1/2 -translate-x-1/2 rotate-90 active:-translate-x-1/2!",
        className,
      )}
      disabled={!canScrollPrev}
      onClick={scrollPrev}
      {...props}
    >
      <ChevronLeftIcon />
      <span className="sr-only">Previous slide</span>
    </Button>
  );
}

function CarouselNext({
  className,
  variant = "outline",
  size = "icon-sm",
  ...props
}: React.ComponentProps<typeof Button>) {
  const { orientation, scrollNext, canScrollNext } = useCarousel();

  return (
    <Button
      data-slot="carousel-next"
      variant={variant}
      size={size}
      className={cn(
        "absolute touch-manipulation rounded-full",
        orientation === "horizontal"
          ? "top-1/2 -right-12 -translate-y-1/2 active:-translate-y-1/2!"
          : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90 active:-translate-x-1/2!",
        className,
      )}
      disabled={!canScrollNext}
      onClick={scrollNext}
      {...props}
    >
      <ChevronRightIcon />
      <span className="sr-only">Next slide</span>
    </Button>
  );
}

export {
  Carousel,
  type CarouselApi,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
  useCarousel,
};

Chart

Recharts wrapper with Flow-token theming. Exports: ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle, ChartConfig (type).

View source on GitHub →

Usage:

import {
  ChartContainer, ChartTooltip, ChartTooltipContent,
} from "@flow-industries/ui/components/chart";
import { Bar, BarChart, XAxis, YAxis } from "recharts";

const chartConfig = {
  visits: { label: "Visits", color: "var(--color-blue)" },
};

<ChartContainer config={chartConfig}>
  <BarChart data={data}>
    <XAxis dataKey="month" />
    <YAxis />
    <ChartTooltip content={<ChartTooltipContent />} />
    <Bar dataKey="visits" fill="var(--color-visits)" />
  </BarChart>
</ChartContainer>

The Chart component is ~390 lines of recharts integration code. For copy-paste usage, view the full source at the link above. Colors resolve against Flow tokens so charts automatically swap palettes when .dark toggles.

Avatar

User image with fallback, badge, and group composition. Exports: Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge, AvatarPlaceholder. Sizes: sm, default, lg. Avatars use the rounded-squircle shape rather than a full circle, and an empty AvatarFallback renders AvatarPlaceholder — an anonymous head-and-shoulders silhouette — by default.

View source on GitHub →

Usage:

import {
  Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarBadge,
} from "@flow-industries/ui/components/avatar";

<Avatar>
  <AvatarImage src="/alice.jpg" alt="Alice" />
  <AvatarFallback>AL</AvatarFallback>
  <AvatarBadge />
</Avatar>

<AvatarGroup>
  <Avatar><AvatarImage src="/a.jpg" /><AvatarFallback>A</AvatarFallback></Avatar>
  <Avatar><AvatarImage src="/b.jpg" /><AvatarFallback>B</AvatarFallback></Avatar>
  <Avatar><AvatarImage src="/c.jpg" /><AvatarFallback>C</AvatarFallback></Avatar>
</AvatarGroup>

Source:

components/ui/avatar.tsx
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import type * as React from "react";

import { cn } from "../../utils/cn";

function Avatar({
  className,
  size = "default",
  ...props
}: AvatarPrimitive.Root.Props & {
  size?: "default" | "sm" | "lg";
}) {
  return (
    <AvatarPrimitive.Root
      data-slot="avatar"
      data-size={size}
      className={cn(
        "group/avatar relative flex size-8 shrink-0 rounded-squircle select-none after:absolute after:inset-0 after:rounded-squircle after:border-[length:var(--border-width)] after:border-secondary after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
        className,
      )}
      {...props}
    />
  );
}

function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
  return (
    <AvatarPrimitive.Image
      data-slot="avatar-image"
      className={cn(
        "aspect-square size-full rounded-squircle object-cover",
        className,
      )}
      {...props}
    />
  );
}

/**
 * Anonymous head-and-shoulders silhouette — the stand-in for a user with no
 * avatar image. Scales to its box, so it works at any avatar size. The
 * shoulders are stroked with a round join so their base corners stay as soft
 * as the squircle they sit in.
 */
function AvatarPlaceholder({
  className,
  ...props
}: React.ComponentProps<"svg">) {
  return (
    <svg
      data-slot="avatar-placeholder"
      viewBox="0 0 48 48"
      fill="currentColor"
      aria-hidden="true"
      className={cn("size-full", className)}
      {...props}
    >
      <circle cx="24" cy="18" r="7.7" />
      <path
        d="M15 41.4a9 6 0 0 1 18 0Z"
        stroke="currentColor"
        strokeWidth="6"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function AvatarFallback({
  children,
  className,
  ...props
}: AvatarPrimitive.Fallback.Props) {
  return (
    <AvatarPrimitive.Fallback
      data-slot="avatar-fallback"
      className={cn(
        "flex size-full items-center justify-center rounded-squircle bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
        className,
      )}
      {...props}
    >
      {children ?? <AvatarPlaceholder />}
    </AvatarPrimitive.Fallback>
  );
}

function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="avatar-badge"
      className={cn(
        "absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
        "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
        "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
        "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
        className,
      )}
      {...props}
    />
  );
}

function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="avatar-group"
      className={cn(
        "group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
        className,
      )}
      {...props}
    />
  );
}

function AvatarGroupCount({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="avatar-group-count"
      className={cn(
        "relative flex size-8 shrink-0 items-center justify-center rounded-squircle bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
        className,
      )}
      {...props}
    />
  );
}

export {
  Avatar,
  AvatarBadge,
  AvatarFallback,
  AvatarGroup,
  AvatarGroupCount,
  AvatarImage,
  AvatarPlaceholder,
};

ProfilePill

The signed-in user's identity chip — the username followed by their avatar — used wherever an app shows who is signed in (Flow ID's own pill, the game menu, every app header). It is rendering-only: it holds no session state and fetches nothing, so callers pass whatever identity they already have. It renders a button by default, which makes it a drop-in menu trigger; pass render={<span />} for a non-interactive display, and the hover treatment applies only while it is a button or a link. Sizes: sm, default, lg. The username truncates past 12rem, and a missing or broken image falls back to the anonymous silhouette.

View source on GitHub →

Usage:

import { ProfilePill } from "@flow-industries/ui/components/profile-pill";
import { DropdownMenuTrigger } from "@flow-industries/ui/components/dropdown-menu";

<ProfilePill username={user.username} image={user.image} />

<DropdownMenuTrigger render={<ProfilePill username={user.username} />} />

Source:

components/ui/profile-pill.tsx
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "../../utils/cn";
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";

const profilePillVariants = cva(
  // normal-case/tracking-tight are load-bearing: app headers commonly set
  // `uppercase tracking-widest` on the nav around it and both inherit, so the
  // pill restates the base layer's own defaults rather than accepting them.
  "group/profile-pill inline-flex w-fit items-center rounded-full text-foreground normal-case tracking-tight outline-none transition-colors [&:is(a,button):hover]:text-muted-foreground",
  {
    variants: {
      size: {
        sm: "gap-2 p-0.5 pl-3 font-medium text-sm",
        default: "gap-2.5 p-0.5 pl-3.5 font-medium text-base",
        lg: "gap-3 p-1 pl-4 font-semibold text-lg",
      },
    },
    defaultVariants: {
      size: "default",
    },
  },
);

/**
 * The signed-in user's identity chip — username followed by their avatar — used
 * wherever an app shows who is signed in (Flow ID's own pill, the game menu, and
 * every app header). Rendering only: it holds no session state and fetches
 * nothing, so callers pass whatever identity they already have.
 *
 * Renders a `button` by default, which is also what makes it a drop-in trigger:
 *
 * ```tsx
 * <DropdownMenuTrigger render={<ProfilePill username={user.username} />} />
 * ```
 *
 * Pass `render={<span />}` for a non-interactive display; the hover treatment
 * only applies while it is a button or a link.
 *
 * @param username Shown as-is, truncated past 12rem — callers guard the
 *                 signed-out case rather than passing an empty string.
 * @param image    Avatar URL; falls back to the anonymous silhouette when it is
 *                 absent or fails to load.
 */
function ProfilePill({
  username,
  image,
  size = "default",
  className,
  render,
  ...props
}: useRender.ComponentProps<"button"> &
  VariantProps<typeof profilePillVariants> & {
    username: string;
    image?: string | null;
  }) {
  return useRender({
    defaultTagName: "button",
    props: mergeProps<"button">(
      {
        // Only when we own the element: `type` on a caller-supplied `render`
        // element would be an invalid attribute (and the base layer turns any
        // [type=button] into a pointer cursor, even on a span).
        type: render ? undefined : ("button" as const),
        className: cn(profilePillVariants({ size, className })),
      },
      props,
      {
        children: (
          <>
            <span className="max-w-[12rem] truncate">{username}</span>
            <Avatar size={size ?? "default"}>
              {image ? <AvatarImage src={image} alt="" /> : null}
              <AvatarFallback />
            </Avatar>
          </>
        ),
      },
    ),
    render,
    state: {
      slot: "profile-pill",
      size,
    },
  });
}

export { ProfilePill, profilePillVariants };

Item

Generic list item primitive used for lists, command menus, and rich selection rows. Exports: Item, ItemMedia, ItemContent, ItemActions, ItemGroup, ItemSeparator, ItemTitle, ItemDescription, ItemHeader, ItemFooter.

View source on GitHub →

Usage:

import {
  Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions,
} from "@flow-industries/ui/components/item";

<Item>
  <ItemMedia variant="icon"><FolderIcon /></ItemMedia>
  <ItemContent>
    <ItemTitle>flow/docs</ItemTitle>
    <ItemDescription>React Router + Fumadocs site</ItemDescription>
  </ItemContent>
  <ItemActions>
    <Button size="sm">Open</Button>
  </ItemActions>
</Item>

Variants: default, outline, muted. Sizes: default, sm, xs.

Source:

components/ui/item.tsx
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";

import { cn } from "../../utils/cn";
import { Separator } from "./separator";

const ItemGroupContext = React.createContext(false);

function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <ItemGroupContext.Provider value={true}>
      {/* biome-ignore lint/a11y/useSemanticElements: role="list" on a styled div avoids ul default list styles */}
      <div
        role="list"
        data-slot="item-group"
        className={cn(
          "group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
          className,
        )}
        {...props}
      />
    </ItemGroupContext.Provider>
  );
}

function ItemSeparator({
  className,
  ...props
}: React.ComponentProps<typeof Separator>) {
  return (
    <Separator
      data-slot="item-separator"
      aria-hidden="true"
      orientation="horizontal"
      className={cn("my-2", className)}
      {...props}
    />
  );
}

const itemVariants = cva(
  "group/item flex w-full flex-wrap items-center rounded-lg border-[length:var(--border-width)] text-sm transition-colors duration-100 outline-none focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus [a]:transition-colors [a]:hover:bg-muted",
  {
    variants: {
      variant: {
        default: "border-transparent",
        outline: "border-secondary",
        muted: "border-transparent bg-muted/50",
      },
      size: {
        default: "gap-2.5 px-3 py-2.5",
        sm: "gap-2.5 px-3 py-2.5",
        xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  },
);

function Item({
  className,
  variant = "default",
  size = "default",
  render,
  ...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
  const inGroup = React.useContext(ItemGroupContext);
  return useRender({
    defaultTagName: "div",
    props: mergeProps<"div">(
      {
        className: cn(itemVariants({ variant, size, className })),
        role: inGroup ? "listitem" : undefined,
      },
      props,
    ),
    render,
    state: {
      slot: "item",
      variant,
      size,
    },
  });
}

const itemMediaVariants = cva(
  "flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
  {
    variants: {
      variant: {
        default: "bg-transparent",
        icon: "[&_svg:not([class*='size-'])]:size-4",
        image:
          "size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

function ItemMedia({
  className,
  variant = "default",
  ...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
  return (
    <div
      data-slot="item-media"
      data-variant={variant}
      className={cn(itemMediaVariants({ variant, className }))}
      {...props}
    />
  );
}

function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="item-content"
      className={cn(
        "flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
        className,
      )}
      {...props}
    />
  );
}

function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="item-title"
      className={cn(
        "line-clamp-1 flex w-fit items-center gap-2 text-sm font-medium underline-offset-4",
        className,
      )}
      {...props}
    />
  );
}

function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="item-description"
      className={cn(
        "line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
        className,
      )}
      {...props}
    />
  );
}

function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="item-actions"
      className={cn("flex items-center gap-2", className)}
      {...props}
    />
  );
}

function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="item-header"
      className={cn(
        "flex basis-full items-center justify-between gap-2",
        className,
      )}
      {...props}
    />
  );
}

function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="item-footer"
      className={cn(
        "flex basis-full items-center justify-between gap-2",
        className,
      )}
      {...props}
    />
  );
}

export {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemFooter,
  ItemGroup,
  ItemHeader,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
};

On this page