AniUI

Book Page

Book cover with a page-edge texture and an openAngle-driven spine hinge that swings the cover open.

AniUI Press

The AniUI Way

First Edition · 2026

Web preview — components render natively on iOS & Android
import { BookPage, BookPageCover, BookPageAuthor, BookPageFooter, BookPageTitle, BookPageNote, BookPagePages } from "@/components/ui/book-page";
<BookPage openAngle={35}>
  <BookPagePages />
  <BookPageCover>
    <BookPageAuthor>Anish Lawrence</BookPageAuthor>
    <BookPageFooter>
      <BookPageTitle>Chapter One</BookPageTitle>
      <BookPageNote>First Edition, 2026</BookPageNote>
    </BookPageFooter>
  </BookPageCover>
</BookPage>

Installation#

npx @aniui/cli add book-page

Usage#

import { BookPage, BookPageCover, BookPageAuthor, BookPageFooter, BookPageTitle, BookPageNote, BookPagePages } from "@/components/ui/book-page";
<BookPage openAngle={35}>
  <BookPagePages />
  <BookPageCover>
    <BookPageAuthor>Anish Lawrence</BookPageAuthor>
    <BookPageFooter>
      <BookPageTitle>Chapter One</BookPageTitle>
      <BookPageNote>First Edition, 2026</BookPageNote>
    </BookPageFooter>
  </BookPageCover>
</BookPage>

Source#

components/ui/book-page.tsx
import React, { createContext, useContext, useId, useMemo } from "react";
import { View, Text, Image, Platform } from "react-native";
import type { ImageSourcePropType, StyleProp, ViewStyle } from "react-native";
import Svg, { Defs, Pattern, Rect } from "react-native-svg";
import { cn } from "@/lib/utils";

// Layout constants (mirrors a real hardcover: a narrow spine, inset pages, a
// tighter radius on the spine-side corners than the outer corners).
const BOOK_PAGE_WIDTH = 240;
const BOOK_PAGE_ASPECT_RATIO = 3 / 4;
const BOOK_PAGE_PERSPECTIVE = 1000;
const PAGE_BLEED = 6;
const PAGE_INSET = 4;
const PAGE_SPINE_INSET = 12;
const PAGE_LINE = 3;
const PAGE_GAP = 1;
const COVER_SPINE_WIDTH = 10;
const COVER_RADIUS = 6;
const COVER_SPINE_RADIUS = 2;

const BOOK_PAGE_SERIF_FONT = Platform.select({ ios: "Georgia", android: "serif", default: "serif" });
const BOOK_PAGE_MONO_FONT = Platform.select({ ios: "Menlo", android: "monospace", default: "monospace" });

export type BookPagePalette = {
  cover: string;
  coverText: string;
  spine: string;
  page: string;
  pageEdge: string;
  rule: string;
};

/** Dark cloth-bound cover — the default. */
export const BOOK_PAGE_DARK_PALETTE: BookPagePalette = {
  cover: "#18181B",
  coverText: "#FFFFFF",
  spine: "rgba(0,0,0,0.25)",
  page: "#FFFFFF",
  pageEdge: "#E4E4E7",
  rule: "rgba(255,255,255,0.2)",
};

/** Cream paperback cover. */
export const BOOK_PAGE_PAPER_PALETTE: BookPagePalette = {
  cover: "#FFFBEB",
  coverText: "#18181B",
  spine: "rgba(0,0,0,0.12)",
  page: "#FFFFFF",
  pageEdge: "#E4E4E7",
  rule: "rgba(0,0,0,0.2)",
};

interface BookPageContextValue {
  palette: BookPagePalette;
  width: number;
  openAngle: number;
}

const BookPageContext = createContext<BookPageContextValue | null>(null);

function useBookPageContext(componentName: string): BookPageContextValue {
  const context = useContext(BookPageContext);
  if (!context) {
    throw new Error(`<${componentName} /> must be rendered inside a <BookPage> root.`);
  }
  return context;
}

export interface BookPageProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  children?: React.ReactNode;
  /** Overrides for the default (dark) palette — merge in BOOK_PAGE_PAPER_PALETTE for a paperback look. */
  palette?: Partial<BookPagePalette>;
  width?: number;
  aspectRatio?: number;
  /** Degrees the cover has swung open around its spine hinge. 0 = closed (default). */
  openAngle?: number;
  /** Depth of the 3D perspective applied while the cover is open. Ignored when openAngle is 0. */
  perspective?: number;
}

export function BookPage({
  className,
  children,
  palette,
  width = BOOK_PAGE_WIDTH,
  aspectRatio = BOOK_PAGE_ASPECT_RATIO,
  openAngle = 0,
  perspective = BOOK_PAGE_PERSPECTIVE,
  style,
  ...props
}: BookPageProps) {
  const resolvedPalette = useMemo<BookPagePalette>(
    () => ({ ...BOOK_PAGE_DARK_PALETTE, ...palette }),
    [palette]
  );
  const contextValue = useMemo<BookPageContextValue>(
    () => ({ palette: resolvedPalette, width, openAngle }),
    [resolvedPalette, width, openAngle]
  );

  return (
    <BookPageContext.Provider value={contextValue}>
      <View
        className={cn("relative", className)}
        style={[
          { width, aspectRatio },
          openAngle !== 0 ? { transform: [{ perspective }] } : null,
          style,
        ]}
        accessibilityRole="summary"
        {...props}
      >
        {children}
      </View>
    </BookPageContext.Provider>
  );
}

export interface BookPagePagesProps extends Omit<React.ComponentPropsWithoutRef<typeof View>, "children"> {
  className?: string;
  /** How far the page stack peeks out past the cover's right edge. */
  bleed?: number;
  /** Vertical inset from the top/bottom of the cover. */
  inset?: number;
  /** How far the page stack is tucked in from the spine, so it's revealed as the cover opens. */
  spineInset?: number;
}

/**
 * The page-edge texture that sits behind the cover. A tiled two-tone
 * horizontal-line pattern reads as a stack of paper edges once the cover
 * rotates open. Purely decorative — hidden from screen readers.
 */
export function BookPagePages({
  className,
  style,
  bleed = PAGE_BLEED,
  inset = PAGE_INSET,
  spineInset = PAGE_SPINE_INSET,
  ...props
}: BookPagePagesProps) {
  const { palette } = useBookPageContext("BookPagePages");
  const rawPatternId = useId();
  const patternId = `book-page-pages-${rawPatternId.replace(/[^a-zA-Z0-9]/g, "")}`;
  const tileHeight = PAGE_LINE + PAGE_GAP;

  return (
    <View
      className={cn("absolute", className)}
      style={[{ top: inset, bottom: inset, left: spineInset, right: -bleed }, style as StyleProp<ViewStyle>]}
      accessibilityElementsHidden
      importantForAccessibility="no-hide-descendants"
      {...props}
    >
      <Svg width="100%" height="100%">
        <Defs>
          <Pattern id={patternId} patternUnits="userSpaceOnUse" width={4} height={tileHeight}>
            <Rect x={0} y={0} width={4} height={PAGE_LINE} fill={palette.page} />
            <Rect x={0} y={PAGE_LINE} width={4} height={PAGE_GAP} fill={palette.pageEdge} />
          </Pattern>
        </Defs>
        <Rect x={0} y={0} width="100%" height="100%" fill={`url(#${patternId})`} />
      </Svg>
    </View>
  );
}

export interface BookPageCoverProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  children?: React.ReactNode;
  source?: ImageSourcePropType;
  alt?: string;
  /** Darkening wash over the cover image so text stays legible. Approximated with layered translucent Views (no gradient dependency). Default true. */
  scrim?: boolean;
  spineWidth?: number;
  /** Corner radius for the outer (non-spine) edge of the cover. */
  radius?: number;
}

/**
 * The cover: an image (optional) + scrim + spine shadow + slotted text,
 * hinged around its left (spine) edge so `openAngle` reads as a book cover
 * swinging open rather than a card spinning in place.
 */
export function BookPageCover({
  className,
  children,
  source,
  alt,
  scrim = true,
  spineWidth = COVER_SPINE_WIDTH,
  radius = COVER_RADIUS,
  style,
  ...props
}: BookPageCoverProps) {
  const { palette, width, openAngle } = useBookPageContext("BookPageCover");

  // Rotating a View rotates around its own center by default. Translating by
  // half the width before and after the rotation shifts the pivot to the
  // left edge — the spine — instead.
  const hinge =
    openAngle === 0
      ? null
      : { transform: [{ translateX: -width / 2 }, { rotateY: `${-openAngle}deg` }, { translateX: width / 2 }] };

  return (
    <View
      className={cn("absolute inset-0 justify-between overflow-hidden p-4", className)}
      style={[
        {
          backgroundColor: palette.cover,
          borderTopLeftRadius: COVER_SPINE_RADIUS,
          borderBottomLeftRadius: COVER_SPINE_RADIUS,
          borderTopRightRadius: radius,
          borderBottomRightRadius: radius,
        },
        hinge,
        style as StyleProp<ViewStyle>,
      ]}
      {...props}
    >
      {source ? (
        <Image
          source={source}
          accessibilityLabel={alt}
          resizeMode="cover"
          className="absolute inset-0 h-full w-full"
        />
      ) : null}
      {scrim ? (
        <>
          {/* A top-to-bottom gradient scrim (transparent -> 10% -> 70% black)
              would keep cover text legible over any image, but that needs
              expo-linear-gradient for a single consumer, so this is
              approximated with two flat translucent layers instead of a true
              fade. */}
          <View className="absolute inset-0 bg-black/10" />
          <View className="absolute inset-x-0 bottom-0 h-1/2 bg-black/40" />
        </>
      ) : null}
      {/* Spine shadow: a single flat translucent strip along the spine edge,
          kept flat rather than gradient-faded for the same reason as above. */}
      <View
        className="absolute inset-y-0 left-0"
        style={{ width: spineWidth, backgroundColor: palette.spine }}
      />
      {children}
    </View>
  );
}

export interface BookPageAuthorProps extends React.ComponentPropsWithoutRef<typeof Text> {
  className?: string;
  children?: React.ReactNode;
}

/** Small-caps-style byline, rendered at the top of the cover. */
export function BookPageAuthor({ className, style, ...props }: BookPageAuthorProps) {
  const { palette } = useBookPageContext("BookPageAuthor");
  return (
    <Text
      className={cn("text-xs uppercase opacity-70", className)}
      style={[{ color: palette.coverText, fontFamily: BOOK_PAGE_MONO_FONT, letterSpacing: 2 }, style]}
      {...props}
    />
  );
}

export interface BookPageTitleProps extends React.ComponentPropsWithoutRef<typeof Text> {
  className?: string;
  children?: React.ReactNode;
  numberOfLines?: number;
}

/** Large serif title, the cover's focal text. */
export function BookPageTitle({ className, style, ...props }: BookPageTitleProps) {
  const { palette } = useBookPageContext("BookPageTitle");
  return (
    <Text
      className={cn("text-2xl font-bold", className)}
      style={[
        { color: palette.coverText, fontFamily: BOOK_PAGE_SERIF_FONT, lineHeight: 28, letterSpacing: -0.4 },
        style,
      ]}
      {...props}
    />
  );
}

export interface BookPageNoteProps extends React.ComponentPropsWithoutRef<typeof Text> {
  className?: string;
  children?: React.ReactNode;
}

/** Small printed-edition/ISBN-style note, ruled off from the title above it. */
export function BookPageNote({ className, style, ...props }: BookPageNoteProps) {
  const { palette } = useBookPageContext("BookPageNote");
  return (
    <Text
      className={cn("border-t pt-2 text-xs", className)}
      style={[{ color: palette.coverText, fontFamily: BOOK_PAGE_MONO_FONT, borderTopColor: palette.rule }, style]}
      {...props}
    />
  );
}

export interface BookPageFooterProps extends React.ComponentPropsWithoutRef<typeof View> {
  className?: string;
  children?: React.ReactNode;
}

/** Stacks Title/Note (or any children) at the bottom of the cover — Cover is justify-between, so Footer settles below Author. */
export function BookPageFooter({ className, ...props }: BookPageFooterProps) {
  return <View className={cn("gap-3", className)} {...props} />;
}