FLOWDocs
Components

Layout

Card, Separator, AspectRatio, ScrollArea, Resizable, Accordion, Tabs, Collapsible, Slider, Footer, Header, FlowFooter.

Card

Compound container. Exports: Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter. Variants: default, secondary, muted. Sizes: default, sm.

View source on GitHub →

Usage:

import {
  Card, CardHeader, CardTitle, CardDescription,
  CardAction, CardContent, CardFooter,
} from "@flow-industries/ui/components/card";

<Card>
  <CardHeader>
    <CardTitle>Deploy</CardTitle>
    <CardDescription>Push to production.</CardDescription>
    <CardAction>
      <Button variant="ghost" size="icon"><MoreIcon /></Button>
    </CardAction>
  </CardHeader>
  <CardContent>
    <p>Latest commit: abc1234</p>
  </CardContent>
  <CardFooter>
    <Button>Deploy</Button>
  </CardFooter>
</Card>

Source:

components/ui/card.tsx
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";

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

const cardVariants = cva(
  "group/card flex flex-col gap-4 overflow-hidden rounded-xl shadow-sm py-4 text-sm has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
  {
    variants: {
      variant: {
        default: "bg-background text-foreground",
        secondary: "bg-secondary text-secondary-foreground",
        muted: "bg-muted text-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

function Card({
  className,
  size = "default",
  variant,
  ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" } & VariantProps<
    typeof cardVariants
  >) {
  return (
    <div
      data-slot="card"
      data-size={size}
      className={cn(cardVariants({ variant }), className)}
      {...props}
    />
  );
}

function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-header"
      className={cn(
        "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-1.5 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
        className,
      )}
      {...props}
    />
  );
}

function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-title"
      className={cn(
        "text-base font-medium tracking-tighter group-data-[size=sm]/card:text-sm",
        className,
      )}
      {...props}
    />
  );
}

function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  );
}

function CardAction({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-action"
      className={cn(
        "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
        className,
      )}
      {...props}
    />
  );
}

function CardContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-content"
      className={cn("px-4 group-data-[size=sm]/card:px-1.5", className)}
      {...props}
    />
  );
}

function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-footer"
      className={cn(
        "flex items-center rounded-b-xl border-t-[length:var(--border-width)] bg-muted/50 p-4 group-data-[size=sm]/card:p-1.5",
        className,
      )}
      {...props}
    />
  );
}

export {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
};

Separator

Horizontal or vertical divider.

View source on GitHub →

Usage:

import { Separator } from "@flow-industries/ui/components/separator";

<Separator />
<Separator orientation="vertical" className="h-4" />

Source:

components/ui/separator.tsx
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";

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

function Separator({
  className,
  orientation = "horizontal",
  ...props
}: SeparatorPrimitive.Props) {
  return (
    <SeparatorPrimitive
      data-slot="separator"
      orientation={orientation}
      className={cn(
        "shrink-0 bg-secondary data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
        className,
      )}
      {...props}
    />
  );
}

export { Separator };

AspectRatio

Constrain content to a ratio via a CSS custom property.

View source on GitHub →

Usage:

import { AspectRatio } from "@flow-industries/ui/components/aspect-ratio";

<AspectRatio ratio={16 / 9}>
  <img src="/hero.jpg" className="object-cover" />
</AspectRatio>

Source:

components/ui/aspect-ratio.tsx
import { cn } from "../../utils/cn";
import { cssVars } from "../../utils/css-vars";

function AspectRatio({
  ratio,
  className,
  ...props
}: React.ComponentProps<"div"> & { ratio: number }) {
  return (
    <div
      data-slot="aspect-ratio"
      style={cssVars({ "--ratio": ratio })}
      className={cn("relative aspect-(--ratio)", className)}
      {...props}
    />
  );
}

export { AspectRatio };

ScrollArea

Styled scroll container with a custom track. Exports: ScrollArea, ScrollBar.

View source on GitHub →

Usage:

import { ScrollArea } from "@flow-industries/ui/components/scroll-area";

<ScrollArea className="h-64 w-64">
  {longContent}
</ScrollArea>

Source:

components/ui/scroll-area.tsx
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";

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

function ScrollArea({
  className,
  children,
  ...props
}: ScrollAreaPrimitive.Root.Props) {
  return (
    <ScrollAreaPrimitive.Root
      data-slot="scroll-area"
      className={cn("relative", className)}
      {...props}
    >
      <ScrollAreaPrimitive.Viewport
        data-slot="scroll-area-viewport"
        className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus"
      >
        {children}
      </ScrollAreaPrimitive.Viewport>
      <ScrollBar />
      <ScrollAreaPrimitive.Corner />
    </ScrollAreaPrimitive.Root>
  );
}

function ScrollBar({
  className,
  orientation = "vertical",
  ...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
  return (
    <ScrollAreaPrimitive.Scrollbar
      data-slot="scroll-area-scrollbar"
      data-orientation={orientation}
      orientation={orientation}
      className={cn(
        "flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t-[length:var(--border-width)] data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l-[length:var(--border-width)] data-vertical:border-l-transparent",
        className,
      )}
      {...props}
    >
      <ScrollAreaPrimitive.Thumb
        data-slot="scroll-area-thumb"
        className="relative flex-1 rounded-full bg-secondary"
      />
    </ScrollAreaPrimitive.Scrollbar>
  );
}

export { ScrollArea, ScrollBar };

Resizable

Resizable panels via react-resizable-panels. Exports: ResizablePanelGroup, ResizablePanel, ResizableHandle.

View source on GitHub →

Usage:

import {
  ResizablePanelGroup, ResizablePanel, ResizableHandle,
} from "@flow-industries/ui/components/resizable";

<ResizablePanelGroup direction="horizontal">
  <ResizablePanel defaultSize={25}>Sidebar</ResizablePanel>
  <ResizableHandle withHandle />
  <ResizablePanel defaultSize={75}>Content</ResizablePanel>
</ResizablePanelGroup>

Source:

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

import * as ResizablePrimitive from "react-resizable-panels";

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

function ResizablePanelGroup({
  className,
  ...props
}: ResizablePrimitive.GroupProps) {
  return (
    <ResizablePrimitive.Group
      data-slot="resizable-panel-group"
      className={cn(
        "flex h-full w-full aria-[orientation=vertical]:flex-col",
        className,
      )}
      {...props}
    />
  );
}

function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
  return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}

function ResizableHandle({
  withHandle,
  className,
  ...props
}: ResizablePrimitive.SeparatorProps & {
  withHandle?: boolean;
}) {
  return (
    <ResizablePrimitive.Separator
      data-slot="resizable-handle"
      className={cn(
        "relative flex w-px items-center justify-center bg-secondary after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
        className,
      )}
      {...props}
    >
      {withHandle && (
        <div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-secondary" />
      )}
    </ResizablePrimitive.Separator>
  );
}

export { ResizableHandle, ResizablePanel, ResizablePanelGroup };

Accordion

Animated via motion/react. Respects prefers-reduced-motion. Exports: Accordion, AccordionItem, AccordionTrigger, AccordionContent.

View source on GitHub →

Usage:

import {
  Accordion, AccordionItem, AccordionTrigger, AccordionContent,
} from "@flow-industries/ui/components/accordion";

<Accordion openMultiple={false}>
  <AccordionItem value="install">
    <AccordionTrigger>How do I install?</AccordionTrigger>
    <AccordionContent>Run <code>bun add @flow-industries/ui</code>.</AccordionContent>
  </AccordionItem>
  <AccordionItem value="dark">
    <AccordionTrigger>Does it support dark mode?</AccordionTrigger>
    <AccordionContent>Yes. Add <code>.dark</code> to <code>&lt;html&gt;</code>.</AccordionContent>
  </AccordionItem>
</Accordion>

Source:

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

import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";

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

const AccordionItemContext = React.createContext(false);

function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
  return (
    <AccordionPrimitive.Root
      data-slot="accordion"
      className={cn("flex w-full flex-col", className)}
      {...props}
    />
  );
}

function AccordionItem({
  className,
  onOpenChange,
  ...props
}: AccordionPrimitive.Item.Props) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new MutationObserver(() => {
      setOpen(el.hasAttribute("data-open"));
    });
    setOpen(el.hasAttribute("data-open"));
    observer.observe(el, { attributes: true, attributeFilter: ["data-open"] });
    return () => observer.disconnect();
  }, []);

  return (
    <AccordionItemContext.Provider value={open}>
      <AccordionPrimitive.Item
        ref={ref}
        data-slot="accordion-item"
        className={cn(
          "not-last:border-b-[length:var(--border-width)] py-2",
          className,
        )}
        onOpenChange={onOpenChange}
        {...props}
      />
    </AccordionItemContext.Provider>
  );
}

function AccordionIcon({ open }: { open: boolean }) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <svg
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      className="shrink-0 text-dark-pink"
      data-slot="accordion-trigger-icon"
    >
      <motion.path
        d="M5 12h14"
        animate={{
          rotate: open ? 90 : 0,
          opacity: open ? 0 : 1,
        }}
        style={{ transformOrigin: "center" }}
        transition={{
          duration: shouldReduceMotion ? 0 : 0.15,
          ease: "easeOut",
        }}
      />
      <motion.path
        d="M12 5v14"
        animate={{
          rotate: open ? 90 : 0,
        }}
        transition={{
          duration: shouldReduceMotion ? 0 : 0.225,
          ease: "easeOut",
        }}
        style={{ transformOrigin: "center" }}
      />
    </svg>
  );
}

function AccordionTrigger({
  className,
  children,
  ...props
}: AccordionPrimitive.Trigger.Props) {
  const open = React.useContext(AccordionItemContext);

  return (
    <AccordionPrimitive.Header className="flex">
      <AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border-[length:var(--border-width)] border-transparent py-2.5 text-left text-sm font-medium tracking-tighter transition-[color,background-color,border-color] outline-none focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus aria-disabled:pointer-events-none aria-disabled:opacity-50",
          className,
        )}
        {...props}
      >
        {children}
        <AccordionIcon open={open} />
      </AccordionPrimitive.Trigger>
    </AccordionPrimitive.Header>
  );
}

function AccordionContent({
  className,
  children,
}: {
  className?: string;
  children?: React.ReactNode;
}) {
  const open = React.useContext(AccordionItemContext);
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      data-slot="accordion-content"
      role="region"
      initial={false}
      animate={{
        height: open ? "auto" : 0,
        opacity: open ? 1 : 0,
      }}
      transition={{
        duration: shouldReduceMotion ? 0 : 0.25,
        ease: [0.25, 0.1, 0.25, 1],
      }}
      className="overflow-hidden text-sm"
    >
      <div
        className={cn(
          "pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
          className,
        )}
      >
        {children}
      </div>
    </motion.div>
  );
}

export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

Tabs

Spring-animated pill underline via motion/react. Respects prefers-reduced-motion. Exports: Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants.

View source on GitHub →

Usage:

import {
  Tabs, TabsList, TabsTrigger, TabsContent,
} from "@flow-industries/ui/components/tabs";

<Tabs defaultValue="overview">
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="deploys">Deploys</TabsTrigger>
    <TabsTrigger value="logs">Logs</TabsTrigger>
  </TabsList>
  <TabsContent value="overview">...</TabsContent>
  <TabsContent value="deploys">...</TabsContent>
  <TabsContent value="logs">...</TabsContent>
</Tabs>

Source:

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

import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { cva, type VariantProps } from "class-variance-authority";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";

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

const TabsIdContext = React.createContext("");

function Tabs({
  className,
  orientation = "horizontal",
  ...props
}: TabsPrimitive.Root.Props) {
  const id = React.useId();

  return (
    <TabsIdContext.Provider value={id}>
      <TabsPrimitive.Root
        data-slot="tabs"
        data-orientation={orientation}
        className={cn(
          "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
          className,
        )}
        {...props}
      />
    </TabsIdContext.Provider>
  );
}

const tabsListVariants = cva(
  "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[2px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-10 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
  {
    variants: {
      variant: {
        default: "bg-input",
        line: "gap-1 bg-transparent",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

function TabsList({
  className,
  variant = "default",
  ...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
  return (
    <TabsPrimitive.List
      data-slot="tabs-list"
      data-variant={variant}
      className={cn(tabsListVariants({ variant }), className)}
      {...props}
    />
  );
}

function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
  const tabsId = React.useContext(TabsIdContext);
  const ref = React.useRef<HTMLButtonElement>(null);
  const [active, setActive] = React.useState(false);
  const shouldReduceMotion = useReducedMotion();

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new MutationObserver(() => {
      setActive(el.hasAttribute("data-active"));
    });
    setActive(el.hasAttribute("data-active"));
    observer.observe(el, {
      attributes: true,
      attributeFilter: ["data-active"],
    });
    return () => observer.disconnect();
  }, []);

  return (
    <TabsPrimitive.Tab
      ref={ref}
      data-slot="tabs-trigger"
      className={cn(
        "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border-[length:var(--border-width)] border-transparent px-3 py-2 text-sm font-medium tracking-tighter whitespace-nowrap text-muted-foreground transition-colors group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-[length:var(--border-width-focus)] focus-visible:border-focus disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
        "data-active:text-foreground dark:data-active:text-foreground",
        "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
        className,
      )}
      {...props}
    >
      {active && (
        <motion.span
          layoutId={`tab-pill-${tabsId}`}
          className="absolute inset-0 rounded-md bg-background shadow-xs"
          style={{ zIndex: 0 }}
          transition={
            shouldReduceMotion
              ? { duration: 0 }
              : { type: "spring", bounce: 0.15, duration: 0.35 }
          }
        />
      )}
      <span className="relative z-10">{props.children}</span>
    </TabsPrimitive.Tab>
  );
}

function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
  return (
    <TabsPrimitive.Panel
      data-slot="tabs-content"
      className={cn("flex-1 text-sm outline-none", className)}
      {...props}
    />
  );
}

export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants };

Collapsible

Thin wrapper over Base UI's Collapsible. Exports: Collapsible, CollapsibleTrigger, CollapsibleContent.

View source on GitHub →

Usage:

import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@flow-industries/ui/components/collapsible";

<Collapsible>
  <CollapsibleTrigger render={<Button variant="ghost">Details</Button>} />
  <CollapsibleContent>Hidden content</CollapsibleContent>
</Collapsible>

Source:

components/ui/collapsible.tsx
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";

function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
  return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}

function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
  return (
    <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
  );
}

function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
  return (
    <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
  );
}

export { Collapsible, CollapsibleContent, CollapsibleTrigger };

Slider

Single-value or range. Pass an array for ranges.

View source on GitHub →

Usage:

import { Slider } from "@flow-industries/ui/components/slider";

<Slider defaultValue={[50]} max={100} step={1} />
<Slider defaultValue={[20, 80]} max={100} />

Source:

components/ui/slider.tsx
import { Slider as SliderPrimitive } from "@base-ui/react/slider";

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

function Slider({
  className,
  defaultValue,
  value,
  min = 0,
  max = 100,
  ...props
}: SliderPrimitive.Root.Props) {
  const _values = Array.isArray(value)
    ? value
    : Array.isArray(defaultValue)
      ? defaultValue
      : [min, max];

  return (
    <SliderPrimitive.Root
      className={cn("data-horizontal:w-full data-vertical:h-full", className)}
      data-slot="slider"
      defaultValue={defaultValue}
      value={value}
      min={min}
      max={max}
      thumbAlignment="edge"
      {...props}
    >
      <SliderPrimitive.Control className="relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col">
        <SliderPrimitive.Track
          data-slot="slider-track"
          className="relative grow overflow-hidden rounded-full bg-secondary select-none data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
        >
          <SliderPrimitive.Indicator
            data-slot="slider-range"
            className="bg-primary select-none data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
          />
        </SliderPrimitive.Track>
        {Array.from({ length: _values.length }, (_, index) => (
          <SliderPrimitive.Thumb
            data-slot="slider-thumb"
            aria-label={props["aria-label"]}
            // biome-ignore lint/suspicious/noArrayIndexKey: thumbs are positional, index is the identity
            key={index}
            className="relative block size-3 shrink-0 rounded-full border-[length:var(--border-width)] border-primary bg-white transition-[color,box-shadow] duration-100 ease-(--ease-out) select-none after:absolute after:-inset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
          />
        ))}
      </SliderPrimitive.Control>
    </SliderPrimitive.Root>
  );
}

export { Slider };

Composable footer layout. Exports: Footer, FooterContent, FooterBottom, FooterBrand, FooterSocials, FooterLink, FooterCopyright.

View source on GitHub →

Usage:

import {
  Footer, FooterContent, FooterBottom, FooterBrand,
  FooterSocials, FooterLink, FooterCopyright,
} from "@flow-industries/ui/components/footer";
import { Logomark } from "@flow-industries/ui/logo";
import { XIcon, DiscordIcon, GitHubIcon } from "@flow-industries/ui/icons";

<Footer>
  <FooterContent>
    <NavList>...</NavList>
  </FooterContent>
  <FooterBottom>
    <FooterBrand>
      <Logomark />
      <FooterCopyright>© 2026 Flow</FooterCopyright>
    </FooterBrand>
    <FooterSocials>
      <FooterLink href="https://x.com/flow"><XIcon /></FooterLink>
      <FooterLink href="https://discord.gg/flow"><DiscordIcon /></FooterLink>
      <FooterLink href="https://github.com/flow"><GitHubIcon /></FooterLink>
    </FooterSocials>
  </FooterBottom>
</Footer>

Source:

components/ui/footer.tsx
import type * as React from "react";

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

function Footer({ className, ...props }: React.ComponentProps<"footer">) {
  return (
    <footer
      data-slot="footer"
      className={cn("flex flex-col gap-8", className)}
      {...props}
    />
  );
}

function FooterContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="footer-content"
      className={cn("flex flex-col gap-8", className)}
      {...props}
    />
  );
}

function FooterBottom({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="footer-bottom"
      className={cn(
        "flex items-center justify-between gap-4 pt-6 border-t-[length:var(--border-width)] border-secondary",
        className,
      )}
      {...props}
    />
  );
}

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

function FooterSocials({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="footer-socials"
      className={cn("flex items-center gap-4 text-muted-foreground", className)}
      {...props}
    />
  );
}

function FooterLink({ className, ...props }: React.ComponentProps<"a">) {
  return (
    <a
      data-slot="footer-link"
      className={cn(
        "inline-flex [transition:color_80ms_ease] hover:text-brand -m-0.5 rounded-sm border-[length:var(--border-width)] border-transparent outline-none focus-visible:border-focus",
        className,
      )}
      {...props}
    />
  );
}

function FooterCopyright({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="footer-copyright"
      className={cn("text-xs text-muted-foreground", className)}
      {...props}
    />
  );
}

export {
  Footer,
  FooterBottom,
  FooterBrand,
  FooterContent,
  FooterCopyright,
  FooterLink,
  FooterSocials,
};

App header row: a Flow-branded wordmark on the left, an actions cluster on the right. HeaderBrand renders the Logo plus the FLOW+label wordmark; pass render or onClick and it becomes a link or button, gaining the 44px touch floor and focus border. Sizes: sm, default, lg. Header takes a layout prop — plain (the default) is a bare flex row, while product opts into Flow's shared product-header contract (viewport-inset padding, a reserved first row, and geometry that holds steady through loading and account switches) via the flow-product-header class shipped in base.css. Exports: Header, HeaderBrand, HeaderActions, and the HeaderProps type.

View source on GitHub →

Usage:

import { Header, HeaderBrand, HeaderActions } from "@flow-industries/ui/components/header";
import { Button } from "@flow-industries/ui/components/button";

<Header>
  <HeaderBrand label="ID" render={<a href="/" />} />
  <HeaderActions>
    <Button variant="ghost">Sign in</Button>
    <Button>Get started</Button>
  </HeaderActions>
</Header>

For standalone product chrome, opt into the shared contract with layout="product":

<Header layout="product">
  <HeaderBrand label="ID" render={<a href="/" />} />
  <HeaderActions>{/* account actions */}</HeaderActions>
</Header>

Source:

components/ui/header.tsx
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import type * as React from "react";

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

type HeaderProps = React.ComponentProps<"header"> & {
  layout?: "plain" | "product";
};

function Header({ className, layout = "plain", ...props }: HeaderProps) {
  return (
    <header
      data-slot="header"
      className={cn(
        "flex items-center justify-between gap-4",
        layout === "product" && "flow-product-header",
        className,
      )}
      {...props}
    />
  );
}

const brandSizes = {
  sm: {
    logo: 16,
    gap: "gap-1.5",
    text: "text-sm tracking-widest",
    label: "ml-0.5",
  },
  default: {
    logo: 20,
    gap: "gap-2",
    text: "text-lg tracking-widest",
    label: "ml-0.5",
  },
  lg: {
    logo: 38,
    gap: "gap-3",
    text: "text-2xl tracking-[0.2em]",
    label: "ml-1",
  },
} as const;

type HeaderBrandSize = keyof typeof brandSizes;

type HeaderBrandProps = useRender.ComponentProps<"span"> & {
  label: string;
  start?: string;
  size?: HeaderBrandSize;
  logoSize?: number;
};

function HeaderBrand({
  className,
  label,
  start = "FLOW",
  size = "default",
  logoSize,
  render,
  onClick,
  ...props
}: HeaderBrandProps) {
  const s = brandSizes[size];
  // A bare brand is static text; rendered as a link or button it needs the
  // same touch floor and focus border as every other control.
  const interactive = render !== undefined || onClick !== undefined;

  return useRender({
    defaultTagName: "span",
    props: mergeProps<"span">(
      {
        className: cn(
          "inline-flex shrink-0 items-center leading-tight text-foreground",
          s.gap,
          interactive &&
            "-m-0.5 rounded-sm border-[length:var(--border-width)] border-transparent outline-none pointer-coarse:min-h-11 focus-visible:border-focus",
          className,
        ),
        onClick,
        children: (
          <>
            <Logo size={logoSize ?? s.logo} className="shrink-0" />
            <span className={cn("uppercase", s.text)}>
              <span className="font-extralight">{start}</span>
              <span className={cn("font-semibold", s.label)}>{label}</span>
            </span>
          </>
        ),
      },
      props,
    ),
    render,
    state: {
      slot: "header-brand",
      size,
    },
  });
}

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

export type { HeaderBrandProps, HeaderBrandSize, HeaderProps };
export { Header, HeaderActions, HeaderBrand };

FlowFooter

The canonical Flow footer as a single composition — Products / Resources / Legal link groups, the Flow Industries brand, social links, copyright, and the live status pill. Sites render this instead of re-assembling Footer by hand; pass className for margin and width, statusApiUrl for the status pill, and products / resources / legal to swap a group's items. Exports: FlowFooter, FLOW_LINKS (the canonical product URLs).

View source on GitHub →

Usage:

import { FlowFooter } from "@flow-industries/ui/components/flow-footer";

<FlowFooter
  className="mx-auto max-w-6xl"
  statusApiUrl="https://status.flow.industries/api/status"
/>

Source:

components/ui/flow-footer.tsx
import type * as React from "react";

import { cn } from "../../utils/cn";
import { BlueskyIcon, DiscordIcon, GitHubIcon, XIcon } from "../icons";
import { Logomark } from "../logo";
import {
  Footer,
  FooterBottom,
  FooterBrand,
  FooterContent,
  FooterCopyright,
  FooterLink,
  FooterSocials,
} from "./footer";
import { NavList, NavListGroup, NavListHeader, NavListItem } from "./nav-list";
import { StatusWidget } from "./status-widget";

const FLOW_LINKS = {
  home: "https://flow.industries",
  game: "https://flow.game",
  id: "https://id.flow.industries",
  ui: "https://ui.flow.industries",
  docs: "https://docs.flow.industries",
  status: "https://status.flow.industries",
  x: "https://x.com/flowdotgame",
  discord: "https://discord.gg/g2JXf8t4Vg",
  github: "https://github.com/flow-industries",
  bluesky: "https://bsky.app/profile/flow.industries",
} as const;

type FooterLinkItem = {
  label: React.ReactNode;
  href: string;
  external?: boolean;
};

type FlowFooterProps = React.ComponentProps<"footer"> & {
  statusApiUrl?: string;
  legalBase?: string;
  products?: FooterLinkItem[];
  resources?: FooterLinkItem[];
  legal?: FooterLinkItem[];
  year?: number;
};

const NEW_TAB_HINT = "opens in new tab";
const EXTERNAL_LINK_PROPS = {
  target: "_blank",
  rel: "noopener noreferrer",
} as const;

const socials = [
  { name: "X", href: FLOW_LINKS.x, Icon: XIcon, icon: "size-4" },
  {
    name: "Discord",
    href: FLOW_LINKS.discord,
    Icon: DiscordIcon,
    icon: "size-5",
  },
  { name: "GitHub", href: FLOW_LINKS.github, Icon: GitHubIcon, icon: "size-4" },
  {
    name: "Bluesky",
    href: FLOW_LINKS.bluesky,
    Icon: BlueskyIcon,
    icon: "size-4",
  },
] as const;

const defaultProducts: FooterLinkItem[] = [
  {
    label: <Logomark size="default" start="Flow" end="Game" />,
    href: FLOW_LINKS.game,
  },
  {
    label: <Logomark size="default" start="Flow" end="ID" />,
    href: FLOW_LINKS.id,
  },
  {
    label: <Logomark size="default" start="Flow" end="UI" />,
    href: FLOW_LINKS.ui,
  },
];

const defaultResources: FooterLinkItem[] = [
  { label: "Status", href: FLOW_LINKS.status },
  { label: "GitHub", href: FLOW_LINKS.github, external: true },
  { label: "Discord", href: FLOW_LINKS.discord, external: true },
];

function FooterGroup({
  title,
  items,
}: {
  title: string;
  items: FooterLinkItem[];
}) {
  return (
    <NavListGroup>
      <NavListHeader>{title}</NavListHeader>
      {items.map(({ label, href, external }) => (
        <NavListItem
          key={href}
          href={href}
          {...(external ? EXTERNAL_LINK_PROPS : {})}
        >
          {label}
          {external && <span className="sr-only"> ({NEW_TAB_HINT})</span>}
        </NavListItem>
      ))}
    </NavListGroup>
  );
}

/**
 * The canonical Flow footer: Products / Resources / Legal link groups, the Flow Industries brand,
 * social links, copyright and the live status pill. Every consumer renders this one composition;
 * pass `className` for margins and max-width, and the list props to swap a group's items.
 */
function FlowFooter({
  className,
  statusApiUrl,
  legalBase = FLOW_LINKS.home,
  products = defaultProducts,
  resources = defaultResources,
  legal,
  year = new Date().getFullYear(),
  ...props
}: FlowFooterProps) {
  const legalItems = legal ?? [
    { label: "Terms", href: `${legalBase}/terms` },
    { label: "Privacy", href: `${legalBase}/privacy` },
  ];

  return (
    <Footer
      data-slot="flow-footer"
      className={cn("rounded-xl bg-secondary p-8", className)}
      {...props}
    >
      <FooterContent>
        <NavList aria-label="Footer" className="w-full justify-between">
          <FooterGroup title="Products" items={products} />
          <FooterGroup title="Resources" items={resources} />
          <FooterGroup title="Legal" items={legalItems} />
        </NavList>
      </FooterContent>
      <FooterBottom className="flex-col items-start gap-4">
        <div className="flex w-full flex-wrap items-center justify-between gap-x-6 gap-y-3">
          <FooterBrand>
            <FooterLink
              href={FLOW_LINKS.home}
              className="pointer-coarse:min-h-11 hover:text-foreground"
            >
              <Logomark size="lg" start="Flow" end="Industries" />
            </FooterLink>
          </FooterBrand>
          <FooterSocials>
            {socials.map(({ name, href, Icon, icon }) => (
              <FooterLink
                key={name}
                href={href}
                {...EXTERNAL_LINK_PROPS}
                aria-label={`Flow on ${name} (${NEW_TAB_HINT})`}
                className="-m-1 size-6 items-center justify-center pointer-coarse:m-0 pointer-coarse:size-11"
              >
                <Icon className={icon} aria-hidden="true" />
              </FooterLink>
            ))}
          </FooterSocials>
        </div>
        <div className="flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-3">
          <FooterCopyright>&copy; {year} Flow Industries</FooterCopyright>
          <StatusWidget apiUrl={statusApiUrl} />
        </div>
      </FooterBottom>
    </Footer>
  );
}

export type { FlowFooterProps, FooterLinkItem };
export { FLOW_LINKS, FlowFooter };

On this page