diff --git a/pygitweb/README.md b/pygitweb/README.md
index db45ec4..866203d 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -81,7 +81,8 @@ Load the `/docs` page for a detailed view of routes (below the readme) if in deb
 | blobpatch | `h`, `hb`, `f`, `fp` | `GET /project/{project}?a=blobpatch&h=...&hb=...&f=...&fp=...` | Blob diff as plain unified diff. |
 | remotes | — | `GET /project/{project}?a=remotes` | List repo remotes. |
 | object | `h` | `GET /project/{project}?a=object&h=...` | Show object by type (commit, tree, tag, or blob). |
-| blame | — | | TODO |
+| blame_raw | `h`, `f` | `GET /project/{project}?a=blame_raw&h=...&f=...` | File blame as JSON: array of `{commit_id, commit_msg, commit_author, time, line_range}`. Contiguous lines from the same commit are merged. |
+| blame | `h`, `f` | `GET /project/{project}?a=blame&h=...&f=...` | File blame HTML view (syntax-highlighted source, line numbers, commit links per line range; hover shows author and date). |
 | blame_incremental | — | | TODO |
 | blame_data | — | | TODO |
 | rss | — | | TODO |
diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index c93f0e5..7ee8713 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -6,6 +6,7 @@ Ported from gitweb/gitweb.perl action handlers. Invoked by main.dispatch.
 from __future__ import annotations
 
 import base64
+import html
 import mimetypes
 import os
 from datetime import UTC, datetime, timedelta, timezone
@@ -46,6 +47,17 @@ from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.validation import is_valid_pathname, is_valid_ref_format
 
 
+class BlameHunkInfo(TypedDict):
+	commit_id: str
+	commit_msg: str
+	commit_author: str
+	time: str
+	line_start: int
+	line_end: int
+	author_name: str
+	time_display: str
+
+
 def _split_query_list(values: list[str]) -> list[str]:
 	parts: list[str] = []
 	for v in values:
@@ -687,6 +699,136 @@ def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -
 	return HTMLResponse(f"{pre}{blob_html}{blob_script}{POSTAMBLE}")
 
 
+def _blame_hunk_end_line(hunk: pygit2.BlameHunk) -> int:
+	return hunk.final_start_line_number + hunk.lines_in_hunk - 1
+
+
+def _merge_blame_hunks(repo: pygit2.Repository, blame: pygit2.Blame) -> list[BlameHunkInfo]:
+	out: list[BlameHunkInfo] = []
+	i = 0
+	n = len(blame)
+	while i < n:
+		hunk = blame[i]
+		cid = str(hunk.final_commit_id)
+		line_start = hunk.final_start_line_number
+		line_end = _blame_hunk_end_line(hunk)
+		j = i + 1
+		while j < n:
+			nh = blame[j]
+			if str(nh.final_commit_id) != cid:
+				break
+			if nh.final_start_line_number != line_end + 1:
+				break
+			line_end = _blame_hunk_end_line(nh)
+			j += 1
+		try:
+			bc = repo[cid].peel(pygit2.Commit)
+		except (KeyError, pygit2.GitError, ValueError) as e:
+			raise HTTPException(status_code=500, detail=f"Missing blame commit {cid}") from e
+		auth = bc.author
+		tzinfo = timezone(timedelta(minutes=auth.offset))
+		dt = datetime.fromtimestamp(float(auth.time), tz=tzinfo)
+		out.append({
+			"commit_id": cid,
+			"commit_msg": bc.message.rstrip("\n"),
+			"commit_author": f"{auth.name} <{auth.email}>",
+			"time": dt.isoformat(),
+			"line_start": line_start,
+			"line_end": line_end,
+			"author_name": auth.name,
+			"time_display": _format_date(auth.time, auth.offset),
+		})
+		i = j
+	return out
+
+
+def _blame_for_file(project: str, h: str | None, f: str) -> list[BlameHunkInfo]:
+	if not is_valid_pathname(f):
+		raise HTTPException(status_code=400, detail="Invalid path")
+	if not get_blob_at_ref_path(project, h, f):
+		raise HTTPException(status_code=404, detail="File not found")
+	repo = open_repo(project)
+	base_ref = h or (str(repo.head.target) if repo.head else None)
+	if not base_ref:
+		raise HTTPException(status_code=404, detail="No revision to blame")
+	try:
+		commit_tip = repo.revparse_single(base_ref).peel(pygit2.Commit)
+	except (KeyError, pygit2.GitError, ValueError) as e:
+		raise HTTPException(status_code=400, detail="Invalid revision for blame") from e
+	path_clean = f.strip("/")
+	try:
+		blame = repo.blame(path_clean, newest_commit=commit_tip.id)
+	except (pygit2.GitError, OSError) as e:
+		raise HTTPException(status_code=400, detail=f"Blame failed: {e}") from e
+	return _merge_blame_hunks(repo, blame)
+
+
+def _render_blame_commits_column(project: str, hunks: list[BlameHunkInfo], num_lines: int) -> str:
+	hunk_by_start = {h["line_start"]: h for h in hunks}
+	lines: list[str] = []
+	for line_no in range(1, num_lines + 1):
+		hunk = hunk_by_start.get(line_no)
+		if hunk is None:
+			lines.append("")
+			continue
+		cid = hunk["commit_id"]
+		oid_short = jinja_escape(cid[:7]) or ""
+		commit_link = f"/project/{project}?a=commit&h={quote(cid, safe='')}"
+		tooltip = html.escape(f"{hunk['author_name']} — {hunk['time_display']}", quote=True)
+		lines.append(f'<a href="{commit_link}" class="blame-commit-link" title="{tooltip}">{oid_short}</a>')
+	return "\n".join(lines)
+
+
+def git_blame(project: str, h: str | None, f: str | None) -> HTMLResponse:
+	"""Blame page: file content with per-range commit links and line numbers."""
+	if not f:
+		raise HTTPException(status_code=400, detail="File path (f) required")
+	result = get_blob_at_ref_path(project, h, f)
+	if not result:
+		raise HTTPException(status_code=404, detail="File not found")
+	blob, _ref_oid = result
+	hunks = _blame_for_file(project, h, f)
+	text = to_utf8(blob.data) or ""
+	body = jinja_escape(sanitize(text) or "") or ""
+	lines = text.split("\n")
+	num_lines = max(1, len(lines))
+	line_nums = "\n".join(str(i) for i in range(1, num_lines + 1))
+	blame_commits = _render_blame_commits_column(project, hunks, num_lines)
+	ext = os.path.splitext(f.split("/")[-1])[1].lstrip(".").lower()
+	lang = BLOB_LANG.get(ext, "")
+	lang_attr = f" language-{lang}" if lang else ""
+	blame_html = (
+		f'<div class="blob-view blame-view">'
+		f'<div class="blame-commits" aria-label="Blame commits">{blame_commits}</div>'
+		f'<div class="blob-line-nums" aria-hidden="true">{jinja_escape(line_nums)}</div>'
+		f'<pre class="blob-content"><code class="hljs{lang_attr}">{body}</code></pre>'
+		f"</div>"
+	)
+	blame_script = '<script src="/static/blob-view.js"></script>'
+	pre = PREAMBLE.render(title=f"Blame {f} - {project}", site_name=settings.SITE_NAME)
+	return HTMLResponse(f"{pre}{blame_html}{blame_script}{POSTAMBLE}")
+
+
+def git_blame_raw(project: str, h: str | None, f: str | None) -> JSONResponse:
+	"""Per-file blame as JSON: merged contiguous hunks per commit with line ranges."""
+	if not f:
+		raise HTTPException(status_code=400, detail="File path (f) required")
+	hunks = _blame_for_file(project, h, f)
+	out = [
+		{
+			"commit_id": h["commit_id"],
+			"commit_msg": h["commit_msg"],
+			"commit_author": h["commit_author"],
+			"time": h["time"],
+			"line_range": (
+				str(h["line_start"]) if h["line_start"] == h["line_end"] else f"{h['line_start']}-{h['line_end']}"
+			),
+		}
+		for h in hunks
+	]
+	return JSONResponse(out)
+
+
 def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
 	"""Tree page: list files and directories; directories link to tree with f=path."""
 	if f is not None and not is_valid_pathname(f):
@@ -710,17 +852,22 @@ def git_tree(project: str, h: str | None, f: str | None) -> HTMLResponse:
 	for name, _, _ in dirs:
 		sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
 		link = _tree_url(project, ref_oid, sub_path)
-		rows.append([f'<a href="{link}">{jinja_escape(name)}/</a>', "tree"])
+		rows.append([f'<a href="{link}">{jinja_escape(name)}/</a>', "tree", ""])
 	for name, _, _ in blobs:
 		sub_path = f"{f.strip('/')}/{name}".strip("/") if f else name
 		link = _tree_url(project, ref_oid, sub_path, a="blob")
-		rows.append([f'<a href="{link}">{jinja_escape(name)}</a>', "blob"])
+		blame_link = _tree_url(project, ref_oid, sub_path, a="blame")
+		rows.append([
+			f'<a href="{link}">{jinja_escape(name)}</a>',
+			"blob",
+			f'<a href="{blame_link}">blame</a>',
+		])
 	title_path = f" / {f}" if f else ""
 	pre = PREAMBLE.render(
 		title=f"{jinja_escape(project)}{jinja_escape(title_path)} - Tree",
 		site_name=settings.SITE_NAME,
 	)
-	table = env.get_template("table.html").render(cols=["Name", "Type"], rows=rows)
+	table = env.get_template("table.html").render(cols=["Name", "Type", ""], rows=rows)
 	body_parts = [f"{pre}<p class='breadcrumb'>{breadcrumb_html}</p><h1>Tree{jinja_escape(title_path)}</h1>{table}"]
 	readme = get_readme_at_ref_path(project, ref_oid, f or "")
 	if readme:
diff --git a/pygitweb/config.py b/pygitweb/config.py
index c88c333..ca855d2 100644
--- a/pygitweb/config.py
+++ b/pygitweb/config.py
@@ -17,6 +17,7 @@ ACTIONS: set[str] = {
 	"blame",
 	"blame_incremental",
 	"blame_data",
+	"blame_raw",
 	"blobdiff",
 	"blobpatch",
 	"blob",
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 25e7cf0..395040e 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -26,6 +26,8 @@ from pygitweb import __meta__
 from pygitweb.actions import (
 	SearchFlagQuery,
 	SearchSortQuery,
+	git_blame,
+	git_blame_raw,
 	git_blob,
 	git_blobdiff,
 	git_blobpatch,
@@ -799,6 +801,10 @@ async def dispatch(
 		return git_tree(project, hash_param, file_name)
 	if action in ("blob", "blob_plain"):
 		return git_blob(project, hash_param, file_name, raw=(action == "blob_plain"))
+	if action == "blame":
+		return git_blame(project, hash_param, file_name)
+	if action == "blame_raw":
+		return git_blame_raw(project, hash_param, file_name)
 	if action == "blobdiff":
 		return git_blobdiff(project, h, hb, file_name, file_parent)
 	if action == "blobpatch":
diff --git a/pygitweb/static/main.css b/pygitweb/static/main.css
index c9ea980..0ef4e59 100644
--- a/pygitweb/static/main.css
+++ b/pygitweb/static/main.css
@@ -577,6 +577,31 @@ tbody tr {
 	}
 }
 
+.blob-view.blame-view {
+	.blame-commits {
+		flex-shrink: 0;
+		user-select: none;
+		padding: 0.75rem 0.5rem;
+		text-align: right;
+		color: var(--pgw-text-muted);
+		white-space: pre;
+		font-family: ui-monospace, "Fira Code", "Fira Mono", monospace;
+		font-size: 0.9rem;
+		line-height: 1.5;
+		border-right: 1px solid var(--pgw-border);
+
+		.blame-commit-link {
+			color: var(--pgw-link);
+			text-decoration: none;
+			font-size: 0.85rem;
+
+			&:hover {
+				text-decoration: underline;
+			}
+		}
+	}
+}
+
 /* README markdown: base and all elements use color-scheme variables */
 .markdown-body {
 	color: var(--pgw-text);
