Skip to content
Cookbookv0.22.1
Color scheme
Contrast
Color scheme
Contrast

Custom components

Use this guide after theme configuration when you need markup beyond Cookbook’s built-in surfaces. For ordinary visual changes, consult the component styles reference first.

Authoring custom components

Import styling tools from @tenphi/cookbook/styling. They use the same Tasty runtime as Cookbook, with its semantic colors, typography presets, units, and responsive states. The renderer package also exposes them from @tenphi/renderer/styling.

Export Purpose
defineComponent Create a named Tasty component with its theme.customStyles[name] overrides.
extendComponent Extend an existing Tasty component with a new name and its own theme overrides.
Button Shared button foundation used by Cookbook controls and custom button components.
tasty Use Tasty directly without binding a component to theme.customStyles.
useGlobalStyles Collect a global Tasty style tree while rendering a page.
resolveComponentStyles Merge theme.customStyles[name] into a complete base style tree for global rules.
mergeStyles Compose your own base styles with Tasty’s deep-merge semantics.
Styles TypeScript type for a Tasty style object.

Cookbook supplies the React renderer and extracts these styles into its static CSS. No additional Tasty dependency, Astro integration, or client:* directive is needed for static components. Set shared tokens, presets, and states in docs.config.ts before rendering. Import the styling entry point in component modules; configuration files should import defineDocsConfig() from @tenphi/cookbook/config.

A small named component

For a component used by your pages, give its root a name and define its parts inside the same style object:

// src/components/StatusBadge.ts
import { defineComponent } from "@tenphi/cookbook/styling";

export const StatusBadge = defineComponent("StatusBadge", {
  as: "span",
  styles: {
    display: "inline-flex",
    padding: "0.5x 1x",
    radius: "$radius",
    fill: "#surface-2",
    color: "#text",
    preset: "small",
    Label: { $: "> strong", preset: "strong" },
  },
});

Use <StatusBadge><strong>Stable</strong></StatusBadge> in a local MDX page. A site can change just one part in docs.config.ts:

theme: {
  customStyles: {
    StatusBadge: { Label: { color: "#accent-text" } },
  },
}

theme.customStyles supplies a partial style object. defineComponent() merges it with the base styles while rendering on the server. Document the root name and every named part when sharing a custom component.

Set site.logo once to use your artwork in the header and mobile drawer:

site: {
  title: "Acme",
  logo: "./assets/acme.svg"
}

Paths are local files relative to the resolved project root, just like site.favicon. Cookbook reads the intrinsic dimensions and copies the image to a content-hashed URL under the deployment base. SVG, PNG, JPEG, WebP, AVIF, and GIF are supported. Missing or invalid files fail during setup.

site: {
  title: "Acme",
  logo: {
    light: "./assets/acme-light.svg",
    dark: "./assets/acme-dark.svg",
    alt: "Acme documentation",
    href: "/",
    decorative: true
  },
  favicon: "./assets/acme-icon.svg"
}

Use src for one image, or both light and dark. Variants follow the appearance control and the system preference in Auto mode. They must share an aspect ratio to prevent layout shifts. Optional width and height describe intrinsic dimensions; Tasty controls display size. Customize theme.styles.SiteLogo and its complete anatomy: Image, Light, and Dark. Wide wordmarks retain their proportions within the header.

The logo is decorative by default because the neighboring title labels the site. Set decorative: false and alt when the image itself carries meaningful text. The header logo link still has a name when the image is decorative. href defaults to the localized documentation home; it accepts a root-relative documentation route or HTTP(S) destination. The header title and drawer use the same destination. site.logo: false removes the mark from both locations.

A logo does not change the favicon or touch icons. Configure site.favicon separately for square artwork; the default Cookbook icon set remains in place until changed. Logo remains available as the standalone Cookbook book mark.

The built-in mark uses theme.palette["logo-surface"] and theme.palette["logo-mark"]. The default homepage hero artwork also uses logo-surface for its brand fill. Both default to Glaze mode: "fixed": the brand background and light book keep their polarity in dark mode while respecting Glaze’s tone boundaries and high-contrast settings. The mark-to-background contrast floor is 3:1 normally and 4.5:1 in high contrast. These colors are separate from interactive accents. For example, to customize the logo background while retaining brand hue:

theme: {
  brand: { from: "#315efb" },
  palette: {
    "logo-surface": {
      base: "logo-mark",
      tone: 40,
      saturation: 0.8,
      mode: "fixed",
      contrast: { wcag: [3, 4.5] },
    },
    "logo-mark": { tone: 100, saturation: 0, mode: "fixed" },
  },
}

Custom image assets keep their authored colors; use light/dark files when you want distinct artwork. Header and drawer home links center logos with the site title regardless of their aspect ratio. Use theme.styles.SiteLogo for image size and theme.styles.Header.SiteTitle / Sidebar.HomeLink for typography; they do not need baseline offsets for a different preset.

Advanced site title markup

To put your own logo and title in one home link, create docs/components/site-title.ts:

import { defineComponent } from "@tenphi/cookbook/styling";

export const SiteTitleRoot = defineComponent("ProjectSiteTitle", {
  as: "a",
  styles: {
    display: "flex",
    alignItems: "center",
    gap: "1x",
    inlineSize: "min 0",
    color: "#text",
    preset: { "": "h4 / strong", "@mobile": "h5 / strong" },
    textDecoration: "none",
    Logo: {
      $: "> svg",
      display: "block",
      flexShrink: "0",
      inlineSize: { "": "2rem", "@mobile": "1.75rem" },
      blockSize: { "": "2rem", "@mobile": "1.75rem" },
      color: "#accent-text",
    },
    Label: {
      $: "> span",
      inlineSize: "min 0",
      overflow: "hidden",
      textOverflow: "ellipsis",
      whiteSpace: "nowrap",
    },
  },
});

Then create docs/components/SiteTitle.astro, using an SVG whose paths use currentColor to follow the semantic accent color:

---
import ProjectLogo from "../../public/logo.svg";
import { SiteTitleRoot } from "./site-title.js";

const { siteTitle, siteTitleHref } = Astro.locals.cookbookRoute;
---

<SiteTitleRoot href={siteTitleHref}>
  <ProjectLogo aria-hidden="true" focusable="false" />
  <span translate="no">{siteTitle}</span>
</SiteTitleRoot>

Register the replacement in docs.config.ts and disable the shared mark. A SiteTitle override affects the header; use site.logo above for ordinary branding shared with the drawer. For custom drawer markup, override Sidebar. The custom component supports root styles plus its complete list of named sub-elements: Logo and Label.

site: { logo: false },
components: {
  overrides: {
    SiteTitle: "./docs/components/SiteTitle.astro"
  }
},
theme: {
  customStyles: {
    ProjectSiteTitle: { Logo: { color: "#text" } }
  }
}

defineComponent(name, options) accepts Tasty factory options and a name for theme.customStyles[name]. It merges the configured partial style object before creating the component, retaining the other base properties and responsive states. Tasty options such as elements, variants, styleProps, modProps, and tokenProps keep their behavior and inferred types, including generated subcomponents such as Component.Label. Configured styles become the component’s defaults; variants and styles passed at render time follow Tasty’s usual precedence.

Use defineComponent when authoring a component that should support theme.customStyles. Use tasty directly for ordinary Tasty creation or composition that does not need a configuration name. To define a named component around an existing React component that forwards className, pass it as as in the options. Use className when passing a class to a Tasty component from Astro.

Extending a component

Use extendComponent(name, base, options) to inherit an existing Tasty component’s styles, props, variants, and compound parts:

import { Button, extendComponent } from "@tenphi/cookbook/styling";

export const ProjectButton = extendComponent("ProjectButton", Button, {
  type: "button",
  styles: {
    padding: "1x 2x",
    radius: "$radius",
    fill: "#surface",
    Hover: { $: "&:hover", fill: "#surface-2-hover" },
  },
});

export const CompactButton = extendComponent("CompactButton", ProjectButton, {
  styles: { padding: "0.5x 1x" },
});

theme.styles.Button customizes the shared foundation, including Cookbook’s search and mobile-menu buttons. theme.customStyles.ProjectButton and CompactButton customize each derived component independently. Button has no named sub-elements; this example adds Hover to both descendants.

The merge order is the base component’s configured styles and selected variant, the extension’s styles, its named theme override, and styles passed while rendering. Tasty merges named sub-elements and state maps using its usual extending and replacing rules. Changing a descendant never mutates its base or siblings. If a base exposes parts such as Button.Label, the descendant exposes those same components.

Extension options accept styles and default values for existing props. Define new elements, variants, styleProps, modProps, tokenProps, or an as type on the original factory; Tasty’s wrapping API does not install new factory settings. Existing required props remain required in the derived component’s type. To compose reusable style fragments without a component base, use mergeStyles(baseStyles, extensionStyles); a shallow spread replaces entire nested sub-elements and state maps.

Global style trees

For markup you do not render through a Tasty component, call useGlobalStyles() during rendering. For example, this Astro component supports root styles and a Label sub-element through theme.customStyles.ProjectNote:

---
import {
  resolveComponentStyles,
  useGlobalStyles,
} from "@tenphi/cookbook/styling";

useGlobalStyles(
  ".project-note",
  resolveComponentStyles("ProjectNote", {
    padding: "2x",
    fill: "#surface-2",
    Label: { $: "> strong", color: "#text", preset: "strong" },
  }),
);
---

<aside class="project-note"><strong>Note</strong><slot /></aside>

defineComponent, extendComponent, and resolveComponentStyles read the same theme.customStyles configuration; user configuration contains only the properties to change. Each custom style name must match a call to one of these helpers.

Linting custom styles

@tenphi/cookbook/eslint-plugin re-exports the Tasty ESLint plugin together with Cookbook’s validationConfig. The renderer offers the same exports from @tenphi/renderer/eslint-plugin. Keep style definitions in .ts or .tsx modules, as in the site title example above, to lint them with either ESLint or oxlint.

Create tasty.config.ts at your project root:

export default {
  extends: "@tenphi/cookbook",
};

Renderer-only consumers can use extends: "@tenphi/renderer" instead. The preset registers both Cookbook styling import paths, its built-in tokens, units, responsive states, and typography presets. It also describes defineComponent, extendComponent, resolveComponentStyles, and mergeStyles, so their inline style objects, variants, and named sub-elements receive the same checks as tasty() calls. Partial overrides passed to mergeStyles may omit default state values and retain the plugin’s safeguards against fixes that replace base styles.

ESLint

Install ESLint and a TypeScript parser:

pnpm add -D eslint @typescript-eslint/parser

Add the plugin to eslint.config.mjs:

import parser from "@typescript-eslint/parser";
import tasty from "@tenphi/cookbook/eslint-plugin";

export default [
  {
    ...tasty.configs.recommended,
    files: ["**/*.{ts,tsx}"],
    languageOptions: { parser },
  },
];

Run pnpm exec eslint docs/components --max-warnings 0. Use tasty.configs.strict for additional checks, including custom property names and runtime style values. The named recommended and strict exports contain rule maps for configurations that register the plugin themselves.

oxlint

Install oxlint:

pnpm add -D oxlint

Create oxlint.config.mjs using its JavaScript plugin support:

import { recommended } from "@tenphi/cookbook/eslint-plugin";

export default {
  jsPlugins: [{ name: "tasty", specifier: "@tenphi/cookbook/eslint-plugin" }],
  rules: recommended,
};

Run pnpm exec oxlint --config oxlint.config.mjs docs/components --deny-warnings. The tasty alias keeps rule names such as tasty/known-property consistent with ESLint. Import strict instead of recommended to enable the stricter rule map. Both presets are tested with oxlint 1.83.0; JavaScript plugin support is experimental.

Custom theme names

Add names introduced by your docs.config.ts theme to the validation config. For example, after defining theme.tokens["$project-gap"], theme.states["@project-wide"], and theme.presets["project-title"]:

import type { TastyValidationConfig } from "@tenphi/cookbook/eslint-plugin";

export default {
  extends: "@tenphi/cookbook",
  tokens: ["$project-gap"],
  states: ["@project-wide"],
  presets: ["project-title"],
} satisfies TastyValidationConfig;

extends merges and deduplicates these arrays with the inherited configuration, so list only your additions. It also inherits the styling helper signatures; entries you add to styleFunctions override inherited signatures by function name. The exported validationConfig object remains available for programmatic use. The entry point also exports the upstream StyleFunctionConfig and ResolvedConfig types for shared lint configurations. Additional imported helpers can be registered in styleFunctions; use partial: true for helpers that merge overrides into existing styles.