Editorial

Features

Feature list parts: a Grid rendered as a list in one to four columns or a divided single column, with feature, title and description parts and an outline item that rules its left edge.

Free, MIT licenseOpen in Browse
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 "cn"

import { Grid, type GridProps } from "@/components/editorial/grid"

const featuresVariants = cva("group/feature-group", {
  variants: {
    variant: {
      default: "",
      divided: "divide-y divide-foreground/10",
    },
  },
  defaultVariants: { variant: "default" },
})
const FEATURE_GAP = { default: "lg", divided: "none" } as const

// A Grid rendered as a list. Grid owns the container, columns and gap; this
// keeps the variant styling and defaults. gap="sm" or "xs" suits card items.
// divided is a single-column list: its dividers run between rows, so cols is 1.
type FeatureGroupProps = useRender.ComponentProps<"ul"> & {
  /**
   * Space between features: `xs` 8px, `sm` 16px, `md` 24px, `lg` 40px, `xl` 64px, `px` 1px, `none` 0.
   * Unset, `default` uses `lg` and `divided` uses `none`. `sm` or `xs` suits
   * card-style features.
   */
  gap?: GridProps["gap"]
} & (
    | {
        /**
         * `default` sets the features in columns. `divided` stacks them in
         * one column with a rule between each.
         */
        variant?: "default"
        /**
         * The most columns the features use once the list is wide enough. A
         * `divided` list is always one column.
         */
        cols?: 1 | 2 | 3 | 4
      }
    | {
        /**
         * `default` sets the features in columns. `divided` stacks them in
         * one column with a rule between each.
         */
        variant: "divided"
        /**
         * The most columns the features use once the list is wide enough. A
         * `divided` list is always one column.
         */
        cols?: 1
      }
  )

/**
 * A list of features, rendered as a `<ul>` laid out in columns. Put a
 * `Feature` inside for each one.
 *
 * `default` uses up to four columns, three unless you set `cols`. `divided`
 * is one column with a rule between features. `className` lands on the list,
 * but its columns follow the width of a wrapper around it, so to narrow the
 * list, size its parent.
 */
function FeatureGroup({
  className,
  variant = "default",
  cols = 3,
  gap,
  render,
  ...props
}: FeatureGroupProps) {
  return (
    <Grid
      data-slot="feature-group"
      data-variant={variant}
      role="list"
      render={(render as GridProps["render"]) ?? <ul />}
      cols={variant === "divided" ? 1 : cols}
      gap={gap ?? FEATURE_GAP[variant]}
      className={cn(featuresVariants({ variant }), className)}
      // Grid's props are typed for its default div; this group renders a list.
      {...(props as Omit<GridProps, "cols" | "gap">)}
    />
  )
}

// A card inside fills the item, so every card in a row ends at the same height.
// The item reads the list: divided pads its row.
// outline is the ruled item: a faint rule down its left, the title's stretch
// of it in the primary ink (an address, a contact, an office).
const featureVariants = cva(
  "group/feature flex flex-col gap-2 text-base/7 group-data-[variant=divided]/feature-group:py-8 group-data-[variant=divided]/feature-group:first:pt-0 group-data-[variant=divided]/feature-group:last:pb-0 *:data-[slot=card]:flex-1",
  {
    variants: {
      /**
       * The item style. `default` (the default) adds nothing. `outline`
       * draws a 1px rule in `foreground` at 10% opacity down the left side,
       * sets the text 24px from it, and colors the stretch of the rule
       * beside `FeatureTitle` in `primary`.
       */
      variant: {
        default: "",
        outline: "border-l border-foreground/10 pl-6",
      },
    },
    defaultVariants: { variant: "default" },
  }
)

/**
 * One feature inside `FeatureGroup`, rendered as an `<li>`. Put a
 * `FeatureTitle` and a `FeatureDescription` inside, or a `Card`. A `Card`
 * placed directly inside fills the item, so cards in a row end at the same
 * height.
 *
 * `outline` draws a faint rule down the left side and colors the part of it
 * beside `FeatureTitle` in the primary color. It suits short entries such as
 * an address, a contact or an office.
 */
function Feature({
  className,
  variant = "default",
  render,
  ...props
}: useRender.ComponentProps<
  "li",
  { slot: "feature" } & VariantProps<typeof featureVariants>
> &
  VariantProps<typeof featureVariants>) {
  return useRender({
    defaultTagName: "li",
    render,
    state: { slot: "feature", variant },
    props: mergeProps<"li">(
      { className: cn(featureVariants({ variant }), className) },
      props
    ),
  })
}

type FeatureTitleState = { slot: "feature-title" }

/**
 * The feature's heading, an `<h3>` in semibold text by default. Pass `render`
 * to use another heading level.
 */
function FeatureTitle({
  className,
  render,
  ...props
}: useRender.ComponentProps<"h3", FeatureTitleState>) {
  return useRender({
    defaultTagName: "h3",
    render,
    state: { slot: "feature-title" },
    props: mergeProps<"h3">(
      {
        className: cn(
          "font-semibold group-data-[variant=outline]/feature:relative group-data-[variant=outline]/feature:before:absolute group-data-[variant=outline]/feature:before:inset-y-0 group-data-[variant=outline]/feature:before:-left-[25px] group-data-[variant=outline]/feature:before:border-l group-data-[variant=outline]/feature:before:border-primary",
          className
        ),
      },
      props
    ),
  })
}

/**
 * The feature's supporting text, in muted color. It renders one `<p>`; for a
 * list of points, pass `render={<ul role="list" />}`.
 */
function FeatureDescription({
  className,
  render,
  ...props
}: useRender.ComponentProps<"p">) {
  return useRender({
    defaultTagName: "p",
    render,
    state: { slot: "feature-description" },
    props: mergeProps<"p">(
      {
        className: cn(
          "text-pretty text-muted-foreground not-italic",
          className
        ),
      },
      props
    ),
  })
}

export { FeatureGroup, Feature, FeatureTitle, FeatureDescription }

Installation

pnpm dlx shadcn add @01orbital/features
npx shadcn add @01orbital/features
yarn shadcn add @01orbital/features
bunx --bun shadcn add @01orbital/features

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/editorial/features.tsx, as source you own and edit.

The example also uses section. Install it too:

pnpm dlx shadcn add @01orbital/section
npx shadcn add @01orbital/section
yarn shadcn add @01orbital/section
bunx --bun shadcn add @01orbital/section

Usage

import {
  Feature,
  FeatureDescription,
  FeatureGroup,
  FeatureTitle,
} from "@/components/editorial/features"
import {
  Section,
  SectionContent,
} from "@/components/editorial/section"

const COPY = {
  items: [
    {
      title: "Your tokens, not ours",
      body: "Every block reads the colors and type you already set.",
    },
    {
      title: "Installed in a minute",
      body: "One command puts the source in your repo, yours to edit.",
    },
    {
      title: "Accessible by default",
      body: "Focus, contrast and names are handled before you touch a line.",
    },
  ],
} as const

export default function FeaturesDemo() {
  return (
    <Section>
      <SectionContent>
        <FeatureGroup cols={3}>
          {COPY.items.map(({ title, body }) => (
            <Feature key={title}>
              <FeatureTitle>{title}</FeatureTitle>
              <FeatureDescription>{body}</FeatureDescription>
            </Feature>
          ))}
        </FeatureGroup>
      </SectionContent>
    </Section>
  )
}

Anatomy

Features is built from 4 parts. In the example they nest like this:

FeatureGroup
└─ Feature
   ├─ FeatureTitle
   └─ FeatureDescription
  • FeatureGroup: A list of features, rendered as a <ul> laid out in columns. Put a Feature inside for each one.
  • Feature: One feature inside FeatureGroup, rendered as an <li>. Put a FeatureTitle and a FeatureDescription inside, or a Card. A Card placed directly inside fills the item, so cards in a row end at the same height.
  • FeatureTitle: The feature's heading, an <h3> in semibold text by default. Pass render to use another heading level.
  • FeatureDescription: The feature's supporting text, in muted color. It renders one <p>; for a list of points, pass render={<ul role="list" />}.

API reference

FeatureGroup

A list of features, rendered as a <ul> laid out in columns. Put a Feature inside for each one.

default uses up to four columns, three unless you set cols. divided is one column with a rule between features. className lands on the list, but its columns follow the width of a wrapper around it, so to narrow the list, size its parent.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

Feature

One feature inside FeatureGroup, rendered as an <li>. Put a FeatureTitle and a FeatureDescription inside, or a Card. A Card placed directly inside fills the item, so cards in a row end at the same height.

outline draws a faint rule down the left side and colors the part of it beside FeatureTitle in the primary color. It suits short entries such as an address, a contact or an office.

Prop

Type

It also accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

FeatureTitle

The feature's heading, an <h3> in semibold text by default. Pass render to use another heading level.

It accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

FeatureDescription

The feature's supporting text, in muted color. It renders one <p>; for a list of points, pass render={<ul role="list" />}.

It accepts the props of the Base UI part it wraps, and the HTML attributes of the element it renders.

Dependencies

npm: @base-ui/react, class-variance-authority, cn. The CLI installs them for you.

Registry pieces it installs with it:

  • grid