diff --git a/.changeset/add-list-style-prop.md b/.changeset/add-list-style-prop.md
new file mode 100644
index 00000000..333c9250
--- /dev/null
+++ b/.changeset/add-list-style-prop.md
@@ -0,0 +1,10 @@
+---
+"streamdown": minor
+---
+
+Add `listStyle` prop for hierarchical bullet styling in nested lists.
+
+- New `listStyle` prop accepts `"flat"` or `"hierarchical"` (default: `"hierarchical"`)
+- Hierarchical mode cycles bullet styles through disc → circle → square based on nesting depth
+- Flat mode preserves the previous uniform `list-disc` behavior
+- All list elements (`
`, ``, `- `) now receive a `data-depth` attribute for custom CSS targeting
diff --git a/apps/website/content/docs/configuration.mdx b/apps/website/content/docs/configuration.mdx
index d87f0b25..520469b7 100644
--- a/apps/website/content/docs/configuration.mdx
+++ b/apps/website/content/docs/configuration.mdx
@@ -70,6 +70,11 @@ Streamdown can be configured to suit your needs. This guide will walk you throug
type: "[ThemeInput, ThemeInput]",
default: "['github-light', 'github-dark']",
},
+ listStyle: {
+ description: "Bullet style preset for nested unordered lists. 'hierarchical' cycles through disc → circle → square based on nesting depth. 'flat' uses disc at all levels.",
+ type: '"hierarchical" | "flat"',
+ default: '"hierarchical"',
+ },
components: {
description: "Custom component overrides for Markdown elements",
type: "object",
diff --git a/packages/streamdown/__tests__/components.test.tsx b/packages/streamdown/__tests__/components.test.tsx
index 9c62b6d8..7c5959c7 100644
--- a/packages/streamdown/__tests__/components.test.tsx
+++ b/packages/streamdown/__tests__/components.test.tsx
@@ -11,6 +11,8 @@ const createContextValue = (
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
+ lineNumbers: true,
+ listStyle: "hierarchical",
mode: "streaming",
mermaid: undefined,
linkSafety,
@@ -54,7 +56,6 @@ describe("Markdown Components", () => {
const ul = container.querySelector("ul");
expect(ul).toBeTruthy();
expect(ul?.className).toContain("list-inside");
- expect(ul?.className).toContain("list-disc");
expect(ul?.className).toContain("whitespace-normal");
});
diff --git a/packages/streamdown/__tests__/link-safety.test.tsx b/packages/streamdown/__tests__/link-safety.test.tsx
index 442e8f1b..e771b03a 100644
--- a/packages/streamdown/__tests__/link-safety.test.tsx
+++ b/packages/streamdown/__tests__/link-safety.test.tsx
@@ -14,6 +14,8 @@ describe("Link Safety Modal", () => {
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
+ lineNumbers: true,
+ listStyle: "hierarchical",
mode: "streaming",
mermaid: undefined,
linkSafety,
diff --git a/packages/streamdown/__tests__/list-style.test.tsx b/packages/streamdown/__tests__/list-style.test.tsx
new file mode 100644
index 00000000..294f8273
--- /dev/null
+++ b/packages/streamdown/__tests__/list-style.test.tsx
@@ -0,0 +1,169 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { StreamdownContext, type StreamdownContextType } from "../index";
+import { components as customComponents } from "../lib/components";
+import { Markdown } from "../lib/markdown";
+
+const createContextValue = (
+ overrides?: Partial
+): StreamdownContextType => ({
+ shikiTheme: ["github-light", "github-dark"],
+ controls: true,
+ isAnimating: false,
+ lineNumbers: true,
+ listStyle: "hierarchical",
+ mode: "streaming",
+ mermaid: undefined,
+ linkSafety: undefined,
+ ...overrides,
+});
+
+const renderWithComponents = (
+ content: string,
+ contextOverrides?: Partial
+) =>
+ render(
+
+ {content}
+
+ );
+
+describe("List Style and Depth", () => {
+ it("should add data-depth attributes to nested unordered lists", () => {
+ const content = `- Item 1
+ - Nested 1
+ - Deep 1
+ - Nested 2
+- Item 2`;
+ const { container } = renderWithComponents(content);
+ const uls = container.querySelectorAll(
+ '[data-streamdown="unordered-list"]'
+ );
+ expect(uls.length).toBeGreaterThanOrEqual(2);
+ expect(uls[0]?.getAttribute("data-depth")).toBe("0");
+ if (uls[1]) {
+ expect(uls[1].getAttribute("data-depth")).toBe("1");
+ }
+ });
+
+ it("should add data-depth attributes to nested ordered lists", () => {
+ const content = `1. First
+ 1. Sub first
+ 2. Sub second
+2. Second`;
+ const { container } = renderWithComponents(content);
+ const ols = container.querySelectorAll(
+ '[data-streamdown="ordered-list"]'
+ );
+ expect(ols.length).toBeGreaterThanOrEqual(1);
+ expect(ols[0]?.getAttribute("data-depth")).toBe("0");
+ });
+
+ it("should add data-depth attributes to list items", () => {
+ const content = `- A
+ - B
+ - C`;
+ const { container } = renderWithComponents(content);
+ const lis = container.querySelectorAll(
+ '[data-streamdown="list-item"]'
+ );
+ expect(lis.length).toBeGreaterThanOrEqual(3);
+ expect(lis[0]?.getAttribute("data-depth")).toBe("0");
+ expect(lis[1]?.getAttribute("data-depth")).toBe("1");
+ expect(lis[2]?.getAttribute("data-depth")).toBe("2");
+ });
+
+ it("should apply list-disc to all li in flat mode", () => {
+ const content = `- Item 1
+ - Nested 1`;
+ const { container } = renderWithComponents(content, {
+ listStyle: "flat",
+ });
+ const lis = container.querySelectorAll(
+ '[data-streamdown="list-item"]'
+ );
+ for (const li of lis) {
+ expect(li.className).toContain("list-disc");
+ }
+ });
+
+ it("should cycle bullet styles in hierarchical mode (default)", () => {
+ const content = `- Level 0
+ - Level 1
+ - Level 2`;
+ const { container } = renderWithComponents(content);
+ const lis = container.querySelectorAll(
+ '[data-streamdown="list-item"]'
+ );
+ expect(lis.length).toBeGreaterThanOrEqual(3);
+ expect(lis[0]?.className).toContain("list-disc");
+ expect(lis[1]?.className).toContain("list-[circle]");
+ expect(lis[2]?.className).toContain("list-[square]");
+ });
+
+ it("should not apply bullet styles to li inside ordered lists", () => {
+ const content = `1. First
+2. Second`;
+ const { container } = renderWithComponents(content);
+ const lis = container.querySelectorAll(
+ '[data-streamdown="list-item"]'
+ );
+ for (const li of lis) {
+ expect(li.className).not.toContain("list-disc");
+ expect(li.className).not.toContain("list-[circle]");
+ expect(li.className).not.toContain("list-[square]");
+ }
+ });
+
+ it("should not have bullet class on ul (bullet style is on li)", () => {
+ const content = `- Item 1
+- Item 2`;
+ const { container } = renderWithComponents(content);
+ const ul = container.querySelector(
+ '[data-streamdown="unordered-list"]'
+ );
+ expect(ul).toBeTruthy();
+ expect(ul?.className).toContain("list-inside");
+ expect(ul?.className).not.toContain("list-disc");
+ });
+
+ it("sibling lists should both start at depth 0", () => {
+ const content = `- List A1
+- List A2
+
+Some text
+
+- List B1
+- List B2`;
+ const { container } = renderWithComponents(content);
+ const uls = container.querySelectorAll(
+ '[data-streamdown="unordered-list"]'
+ );
+ for (const ul of uls) {
+ expect(ul.getAttribute("data-depth")).toBe("0");
+ }
+ });
+
+ it("should track ul depth separately from ol depth for bullet styles", () => {
+ const content = `- Unordered
+ 1. Ordered inside
+ - Deep unordered`;
+ const { container } = renderWithComponents(content, {
+ listStyle: "hierarchical",
+ });
+ const lis = container.querySelectorAll(
+ '[data-streamdown="list-item"]'
+ );
+ // The top ul li should be disc
+ expect(lis[0]?.className).toContain("list-disc");
+ // The ol > li should NOT have bullet styles
+ if (lis[1]) {
+ expect(lis[1].className).not.toContain("list-disc");
+ expect(lis[1].className).not.toContain("list-[circle]");
+ }
+ // The nested ul > li inside ol should be circle (ulDepth=1)
+ if (lis[2]) {
+ expect(lis[2].className).toContain("list-[circle]");
+ }
+ });
+});
diff --git a/packages/streamdown/__tests__/node-attribute-removed.test.tsx b/packages/streamdown/__tests__/node-attribute-removed.test.tsx
index 7dd436f8..0f5aa853 100644
--- a/packages/streamdown/__tests__/node-attribute-removed.test.tsx
+++ b/packages/streamdown/__tests__/node-attribute-removed.test.tsx
@@ -10,6 +10,8 @@ const createContextValue = (
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
+ lineNumbers: true,
+ listStyle: "hierarchical",
mode: "streaming",
mermaid: undefined,
linkSafety,
@@ -83,7 +85,7 @@ describe("Node Attribute Fix", () => {
// ✅ Verify correct attributes ARE present
expect(ul?.getAttribute("data-streamdown")).toBe("unordered-list");
- expect(ul?.className).toContain("list-disc");
+ expect(ul?.className).toContain("list-inside");
});
it("should NOT render node attribute in LI element", () => {
diff --git a/packages/streamdown/index.tsx b/packages/streamdown/index.tsx
index be32d9fd..2329f349 100644
--- a/packages/streamdown/index.tsx
+++ b/packages/streamdown/index.tsx
@@ -200,6 +200,8 @@ export type StreamdownProps = Options & {
isAnimating?: boolean;
animated?: boolean | AnimateOptions;
caret?: keyof typeof carets;
+ /** Bullet style cycling for nested unordered lists. @default "hierarchical" */
+ listStyle?: ListStylePreset;
plugins?: PluginConfig;
remend?: RemendOptions;
linkSafety?: LinkSafetyConfig;
@@ -276,6 +278,13 @@ const carets = {
circle: " ●",
};
+/**
+ * Built-in list bullet style presets for nested unordered lists.
+ * - `"flat"` — all levels use disc (default, current behavior)
+ * - `"hierarchical"` — disc → circle → square, cycling
+ */
+export type ListStylePreset = "flat" | "hierarchical";
+
// Combined context for better performance - reduces React tree depth from 5 nested providers to 1
export interface StreamdownContextType {
controls: ControlsConfig;
@@ -283,6 +292,8 @@ export interface StreamdownContextType {
/** Show line numbers in code blocks. @default true */
lineNumbers: boolean;
linkSafety?: LinkSafetyConfig;
+ /** Bullet style cycling for nested unordered lists. @default "hierarchical" */
+ listStyle: ListStylePreset;
mermaid?: MermaidOptions;
mode: "static" | "streaming";
shikiTheme: [ThemeInput, ThemeInput];
@@ -302,6 +313,7 @@ const defaultStreamdownContext: StreamdownContextType = {
controls: true,
isAnimating: false,
lineNumbers: true,
+ listStyle: "hierarchical",
mode: "streaming",
mermaid: undefined,
linkSafety: defaultLinkSafetyConfig,
@@ -446,6 +458,7 @@ export const Streamdown = memo(
BlockComponent = Block,
parseMarkdownIntoBlocksFn = parseMarkdownIntoBlocks,
caret,
+ listStyle = "hierarchical",
plugins,
remend: remendOptions,
linkSafety = defaultLinkSafetyConfig,
@@ -613,6 +626,7 @@ export const Streamdown = memo(
controls,
isAnimating,
lineNumbers,
+ listStyle,
mode,
mermaid,
linkSafety,
@@ -622,6 +636,7 @@ export const Streamdown = memo(
controls,
isAnimating,
lineNumbers,
+ listStyle,
mode,
mermaid,
linkSafety,
diff --git a/packages/streamdown/lib/components.tsx b/packages/streamdown/lib/components.tsx
index 2449ca3a..a1a45559 100644
--- a/packages/streamdown/lib/components.tsx
+++ b/packages/streamdown/lib/components.tsx
@@ -1,5 +1,6 @@
import {
cloneElement,
+ createContext,
type DetailedHTMLProps,
type HTMLAttributes,
type ImgHTMLAttributes,
@@ -11,10 +12,11 @@ import {
Suspense,
useCallback,
useContext,
+ useMemo,
useState,
} from "react";
// BundledLanguage type removed - we now support any language string
-import { type ControlsConfig, StreamdownContext } from "../index";
+import { type ControlsConfig, StreamdownContext, type ListStylePreset } from "../index";
import { useIsCodeFenceIncomplete } from "./block-incomplete-context";
import { CodeBlock } from "./code-block";
import { CodeBlockCopyButton } from "./code-block/copy-button";
@@ -163,10 +165,35 @@ const shouldShowMermaidControl = (
return mermaidConfig[controlType] !== false;
};
+interface ListContextValue {
+ /** Total list nesting depth (ul + ol combined) */
+ depth: number;
+ /** Unordered list nesting depth only */
+ ulDepth: number;
+ /** Whether the immediate parent list is a
*/
+ isUnordered: boolean;
+}
+
+const ListContext = createContext({
+ depth: 0,
+ ulDepth: 0,
+ isUnordered: false,
+});
+
+const LI_BULLET_STYLES: Record = {
+ flat: ["list-disc"],
+ hierarchical: ["list-disc", "list-[circle]", "list-[square]"],
+};
+
type OlProps = WithNode;
const MemoOl = memo(
({ children, className, node, ...props }: OlProps) => {
const cn = useCn();
+ const { depth, ulDepth } = useContext(ListContext);
+ const ctxValue = useMemo(
+ () => ({ depth: depth + 1, ulDepth, isUnordered: false }),
+ [depth, ulDepth]
+ );
return (
(
className
)}
data-streamdown="ordered-list"
+ data-depth={depth}
{...props}
>
- {children}
+
+ {children}
+
);
},
@@ -189,10 +219,18 @@ type LiProps = WithNode;
const MemoLi = memo(
({ children, className, node, ...props }: LiProps) => {
const cn = useCn();
+ const { depth, ulDepth, isUnordered } = useContext(ListContext);
+ const { listStyle } = useContext(StreamdownContext);
+ const bulletStyles = LI_BULLET_STYLES[listStyle];
+ const bulletClass =
+ isUnordered && ulDepth > 0
+ ? bulletStyles[(ulDepth - 1) % bulletStyles.length]
+ : undefined;
return (
- p]:inline", className)}
+ className={cn("py-1 [&>p]:inline", bulletClass, className)}
data-streamdown="list-item"
+ data-depth={depth > 0 ? depth - 1 : 0}
{...props}
>
{children}
@@ -207,16 +245,24 @@ type UlProps = WithNode;
const MemoUl = memo(
({ children, className, node, ...props }: UlProps) => {
const cn = useCn();
+ const { depth, ulDepth } = useContext(ListContext);
+ const ctxValue = useMemo(
+ () => ({ depth: depth + 1, ulDepth: ulDepth + 1, isUnordered: true }),
+ [depth, ulDepth]
+ );
return (
- {children}
+
+ {children}
+
);
},