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.
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:
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.
Usage:
import { Separator } from "@flow-industries/ui/components/separator";
<Separator />
<Separator orientation="vertical" className="h-4" />Source:
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.
Usage:
import { AspectRatio } from "@flow-industries/ui/components/aspect-ratio";
<AspectRatio ratio={16 / 9}>
<img src="/hero.jpg" className="object-cover" />
</AspectRatio>Source:
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.
Usage:
import { ScrollArea } from "@flow-industries/ui/components/scroll-area";
<ScrollArea className="h-64 w-64">
{longContent}
</ScrollArea>Source:
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.
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:
"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.
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><html></code>.</AccordionContent>
</AccordionItem>
</Accordion>Source:
"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.
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:
"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.
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:
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.
Usage:
import { Slider } from "@flow-industries/ui/components/slider";
<Slider defaultValue={[50]} max={100} step={1} />
<Slider defaultValue={[20, 80]} max={100} />Source:
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 };Footer
Composable footer layout. Exports: Footer, FooterContent, FooterBottom, FooterBrand, FooterSocials, FooterLink, FooterCopyright.
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:
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,
};Header
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.
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:
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).
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:
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>© {year} Flow Industries</FooterCopyright>
<StatusWidget apiUrl={statusApiUrl} />
</div>
</FooterBottom>
</Footer>
);
}
export type { FlowFooterProps, FooterLinkItem };
export { FLOW_LINKS, FlowFooter };