8starlabs-ui
uijson-viewersource: ok · hit
JSON Viewer
A JSON viewer component
npx shadcn add "https://ui.8starlabs.com/r/json-viewer.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@tabler/icons-react
Registry dependencies
https://ui.8starlabs.com/r/button.jsonhttps://ui.8starlabs.com/r/collapsible.jsonhttps://ui.8starlabs.com/r/separator.jsonhttps://ui.8starlabs.com/r/tooltip.json
Files3 · source cached 10/9/2026, 12:21:58 AM
registry/8starlabs-ui/blocks/json-viewer.tsx
"use client";
import React, { useMemo, useState } from "react";
import type { JSX } from "react";
import { cn } from "@/lib/utils";
import { useIsMobile } from "@/registry/8starlabs-ui/hooks/use-mobile";
import { Button } from "@/registry/8starlabs-ui/blocks/button";
import {
Collapsible,
CollapsibleContent
} from "@/registry/8starlabs-ui/ui/collapsible";
import { Separator } from "@/registry/8starlabs-ui/ui/separator";
import { ChevronRight } from "lucide-react";
import { CopyButton } from "@/registry/8starlabs-ui/ui/copy-button";
interface JsonViewerProps {
data: Record<string, any>;
className?: string;
truncation?: Partial<TruncationSettings>;
showLineNumbers?: boolean;
showColorIndent?: boolean;
collapseOn?: "click" | "doubleClick";
defaultExpanded?: boolean | number;
title?: string;
}
interface TruncationSettings {
enabled: boolean;
itemsPerArray: number;
}
type DataType =
| "string"
| "number"
| "boolean"
| "null"
| "object"
| "array"
| "unknown";
const getDataType = (value: any): DataType => {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
const type = typeof value;
if (
type === "string" ||
type === "number" ||
type === "boolean" ||
type === "object"
) {
return type;
}
return "unknown";
};
const getTypeStyle = (type: DataType): string => {
switch (type) {
case "string":
return "text-green-600 dark:text-green-400";
case "number":
return "text-orange-600 dark:text-orange-400";
case "boolean":
return "text-blue-600 dark:text-blue-400";
case "null":
return "text-gray-500 dark:text-gray-400";
default:
return "";
}
};
const formatRelativeTime = (date: Date): string => {
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (Math.abs(diffInSeconds) < 60) return "just now";
const intervals = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60
};
for (const [unit, seconds] of Object.entries(intervals)) {
const interval = Math.floor(Math.abs(diffInSeconds) / seconds);
if (interval >= 1) {
const suffix = diffInSeconds > 0 ? "ago" : "from now";
return `${interval} ${unit}${interval !== 1 ? "s" : ""} ${suffix}`;
}
}
return "just now";
};
const detectDate = (value: any): Date | null => {
if (typeof value === "string") {
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date;
}
}
} else if (typeof value === "number") {
if (value >= 946684800 && value <= 4102444800) {
return new Date(value * 1000);
}
if (value >= 946684800000 && value <= 4102444800000) {
return new Date(value);
}
}
return null;
};
const SmartValue = React.forwardRef<
any,
{ value: any; type: DataType } & React.HTMLAttributes<HTMLElement>
>(({ value, type, ...props }, ref) => {
const [isExpanded, setIsExpanded] = useState(false);
if (type === "string") {
if (
/^#([0-9A-F]{3}){1,2}$/i.test(value) ||
/^rgba?\(/.test(value) ||
/^hsla?\(/.test(value)
) {
return (
<span
ref={ref}
{...props}
className={cn(
"inline-flex items-center gap-1.5 whitespace-nowrap",
props.className
)}
>
<span
className="w-3 h-3 rounded-[2px] border border-white/20 shrink-0"
style={{ backgroundColor: value }}
/>
<span className="text-green-600 dark:text-green-400">{`'${value}'`}</span>
</span>
);
}
if (/^https?:\/\//.test(value)) {
const isLongUrl = String(value).length > 50;
const isVeryLongUrl = String(value).length > 180;
if (isVeryLongUrl) {
return (
<span className="inline-flex flex-col items-start gap-1">
<a
ref={ref}
href={value}
target="_blank"
rel="noopener noreferrer"
{...(props as any)}
className={cn(
"text-green-600 dark:text-green-400 hover:underline hover:text-blue-600 dark:hover:text-blue-400 transition-colors whitespace-pre-wrap break-all",
!isExpanded && "line-clamp-3",
props.className
)}
style={
!isExpanded
? {
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden"
}
: undefined
}
onClick={(e) => {
e.stopPropagation();
props.onClick?.(e);
}}
>
{`'${value}'`}
</a>
<Button
variant="link"
size="sm"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground underline select-none"
>
{isExpanded ? "Show less" : "Show more"}
</Button>
</span>
);
}
return (
<a
ref={ref}
href={value}
target="_blank"
rel="noopener noreferrer"
{...(props as any)}
className={cn(
"text-green-600 dark:text-green-400 hover:underline hover:text-blue-600 dark:hover:text-blue-400 transition-colors",
isLongUrl ? "whitespace-pre-wrap break-all" : "whitespace-nowrap",
props.className
)}
onClick={(e) => {
e.stopPropagation();
props.onClick?.(e);
}}
>
{`'${value}'`}
</a>
);
}
}
const typeStyle = getTypeStyle(type);
if (type === "string") {
const isLongString = String(value).length > 50;
const isVeryLongString = String(value).length > 180;
if (isVeryLongString) {
return (
<span className="inline-flex flex-col items-start gap-1">
<span
ref={ref}
{...props}
className={cn(
typeStyle,
"whitespace-pre-wrap wrap-break-words",
!isExpanded && "line-clamp-3",
props.className
)}
style={
!isExpanded
? {
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden"
}
: undefined
}
>
{`'${value}'`}
</span>
<Button
variant="link"
size="sm"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground underline select-none"
>
{isExpanded ? "Show less" : "Show more"}
</Button>
</span>
);
}
return (
<span
ref={ref}
{...props}
className={cn(
typeStyle,
isLongString
? "whitespace-pre-wrap wrap-break-words"
: "whitespace-nowrap",
props.className
)}
>
{`'${value}'`}
</span>
);
}
if (type === "null")
return (
<span
ref={ref}
{...props}
className={cn(typeStyle, "whitespace-nowrap", props.className)}
>
null
</span>
);
return (
<span
ref={ref}
{...props}
className={cn(typeStyle, "whitespace-nowrap", props.className)}
>
{String(value)}
</span>
);
});
SmartValue.displayName = "SmartValue";
const calculateLineCount = (
data: any,
expandedPaths: Set<string>,
path = "root",
level = 0,
truncation: TruncationSettings
): number => {
const dataType = getDataType(data);
if (dataType === "object") {
const isOpen = expandedPaths.has(path);
if (!isOpen) return 1;
const entries = Object.entries(data);
if (entries.length === 0) return 2;
return (
2 +
entries.reduce(
(acc, [key, value]) =>
acc +
calculateLineCount(
value,
expandedPaths,
`${path}.${key}`,
level + 1,
truncation
),
0
)
);
}
if (dataType === "array") {
const isOpen = expandedPaths.has(path);
if (!isOpen) return 1;
if (data.length === 0) return 2;
if (truncation.enabled && data.length > truncation.itemsPerArray) {
const visibleItems = data.slice(0, truncation.itemsPerArray);
return (
3 +
visibleItems.reduce(
(acc: number, item: any, index: number) =>
acc +
calculateLineCount(
item,
expandedPaths,
`${path}[${index}]`,
level + 1,
truncation
),
0
)
);
}
return (
2 +
data.reduce(
(acc: number, item: any, index: number) =>
acc +
calculateLineCount(
item,
expandedPaths,
`${path}[${index}]`,
level + 1,
truncation
),
0
)
);
}
return 1;
};
const generateAllPaths = (
data: any,
maxLevel: number = Infinity,
currentLevel: number = 0,
currentPath: string = "root"
): Set<string> => {
const paths = new Set<string>();
if (currentLevel > maxLevel) return paths;
if (typeof data === "object" && data !== null) {
paths.add(currentPath);
if (Array.isArray(data)) {
data.forEach((item, index) => {
const childPaths = generateAllPaths(
item,
maxLevel,
currentLevel + 1,
`${currentPath}[${index}]`
);
childPaths.forEach((path) => paths.add(path));
});
} else {
Object.entries(data).forEach(([key, value]) => {
const childPaths = generateAllPaths(
value,
maxLevel,
currentLevel + 1,
`${currentPath}.${key}`
);
childPaths.forEach((path) => paths.add(path));
});
}
}
return paths;
};
const JsonViewer: React.FC<JsonViewerProps> = ({
data,
className,
truncation: truncationProp,
showLineNumbers = true,
showColorIndent = false,
collapseOn = "click",
defaultExpanded = false,
title
}) => {
const isMobile = useIsMobile();
const [expandedPaths, setExpandedPaths] = React.useState<Set<string>>(() => {
if (typeof defaultExpanded === "number") {
return generateAllPaths(data, defaultExpanded);
}
if (defaultExpanded === true) {
return generateAllPaths(data);
}
const initialPaths = new Set<string>();
if (typeof data === "object" && data !== null) {
initialPaths.add("root");
}
return initialPaths;
});
const expandAll = () => {
setExpandedPaths(generateAllPaths(data));
};
const collapseAll = () => {
setExpandedPaths(new Set(["root"]));
};
const truncation: TruncationSettings = React.useMemo(
() => ({
enabled: isMobile ? false : (truncationProp?.enabled ?? true),
itemsPerArray: truncationProp?.itemsPerArray ?? 5
}),
[truncationProp, isMobile]
);
const toggleNode = (path: string) => {
setExpandedPaths((prev) => {
const newPaths = new Set(prev);
if (newPaths.has(path)) {
newPaths.delete(path);
} else {
newPaths.add(path);
}
return newPaths;
});
};
const lineCount = useMemo(
() => calculateLineCount(data, expandedPaths, "root", 0, truncation),
[data, expandedPaths, truncation]
);
return (
<div
className={cn(
"relative font-mono text-[13px] leading-6 w-full text-foreground bg-secondary/10 dark:bg-muted/50 rounded-md border border-border flex flex-col",
className
)}
>
<div className="flex justify-between items-center p-2 z-10 gap-2">
<div className="text-xs font-medium text-muted-foreground px-2">
{title}
</div>
<div className="flex items-center rounded-md border bg-muted/50 overflow-hidden">
<Button
variant="ghost"
size="sm"
onClick={expandAll}
className="h-7 px-2 text-xs hover:bg-muted rounded-none"
title="Expand All"
>
Expand All
</Button>
<Separator orientation="vertical" className="h-4" />
<Button
variant="ghost"
size="sm"
onClick={collapseAll}
className="h-7 px-2 text-xs hover:bg-muted rounded-none"
title="Collapse All"
>
Collapse All
</Button>
<Separator orientation="vertical" className="h-4" />
<CopyButton
value={JSON.stringify(data, null, 2)}
className="static size-7 bg-transparent hover:bg-muted hover:opacity-100 focus-visible:opacity-100 text-foreground rounded-none"
/>
</div>
</div>
<div className="w-full overflow-auto flex-1 p-4 pt-0">
<pre className="flex">
{showLineNumbers && (
<div className="hidden sm:block">
<LineNumbers lineCount={lineCount} />
</div>
)}
<code>
<JsonNode
data={data}
path="root"
expandedPaths={expandedPaths}
toggleNode={toggleNode}
truncation={truncation}
showColorIndent={showColorIndent}
collapseOn={collapseOn}
/>
</code>
</pre>
</div>
</div>
);
};
const LineNumbers: React.FC<{ lineCount: number }> = ({ lineCount }) => {
return (
<div className="flex flex-col text-right pr-4 text-muted-foreground select-none border-r border-border mr-4">
{Array.from({ length: lineCount }, (_, i) => (
<div key={i} className="h-6 leading-6 text-xs tabular-nums opacity-50">
{i + 1}
</div>
))}
</div>
);
};
interface JsonNodeProps {
data: any;
level?: number;
path: string;
expandedPaths: Set<string>;
toggleNode: (path: string) => void;
showComma?: boolean;
objectKey?: string;
truncation: TruncationSettings;
showColorIndent?: boolean;
collapseOn?: "click" | "doubleClick";
}
const JsonNode: React.FC<JsonNodeProps> = ({
data,
level = 0,
path,
expandedPaths,
toggleNode,
showComma,
objectKey,
truncation,
showColorIndent,
collapseOn
}) => {
const dataType = getDataType(data);
const renderValue = () => {
let element: JSX.Element | null = null;
switch (dataType) {
case "array":
element = (
<JsonArray
data={data}
level={level}
path={path}
expandedPaths={expandedPaths}
toggleNode={toggleNode}
showComma={showComma}
objectKey={objectKey}
truncation={truncation}
showColorIndent={showColorIndent}
collapseOn={collapseOn}
/>
);
break;
case "object":
element = (
<JsonObject
data={data}
level={level}
path={path}
expandedPaths={expandedPaths}
toggleNode={toggleNode}
showComma={showComma}
objectKey={objectKey}
truncation={truncation}
showColorIndent={showColorIndent}
collapseOn={collapseOn}
/>
);
break;
default:
element = <SmartValue value={data} type={dataType} />;
break;
}
if (dataType === "object" || dataType === "array") {
return element;
}
const date = detectDate(data);
if (date) {
const timeStr = formatRelativeTime(date);
return (
<span className="inline-flex items-center gap-2">
{element}
<span className="text-xs text-muted-foreground/60 select-none italic">
{`// ${timeStr}`}
</span>
</span>
);
}
return element;
};
return (
<>
{renderValue()}
{dataType !== "object" && dataType !== "array" && showComma && (
<span className="text-muted-foreground">,</span>
)}
</>
);
};
const indentColors = [
"border-red-300/60 dark:border-red-700/60",
"border-yellow-300/60 dark:border-yellow-700/60",
"border-green-300/60 dark:border-green-700/60",
"border-blue-300/60 dark:border-blue-700/60",
"border-purple-300/60 dark:border-purple-700/60"
];
const JsonObject: React.FC<{
objectKey?: string;
data: Record<string, any>;
level: number;
path: string;
expandedPaths: Set<string>;
toggleNode: (path: string) => void;
showComma?: boolean;
truncation: TruncationSettings;
showColorIndent?: boolean;
collapseOn?: "click" | "doubleClick";
}> = ({
data,
level,
path,
expandedPaths,
toggleNode,
showComma,
objectKey,
truncation,
showColorIndent,
collapseOn
}) => {
const entries = Object.entries(data);
const isOpen = expandedPaths.has(path);
const trigger = (
<div
className={cn(
"inline-flex items-center text-left h-6 leading-6 group rounded-sm px-1 -ml-1 w-full cursor-pointer select-none",
isOpen && "hover:bg-muted-foreground/20"
)}
onDoubleClick={
collapseOn === "doubleClick" ? () => toggleNode(path) : undefined
}
onClick={
collapseOn === "doubleClick"
? undefined
: (e) => {
toggleNode(path);
}
}
>
{objectKey && (
<span className="text-purple-600 dark:text-purple-400 inline-flex items-center group font-medium">
{`'${objectKey}'`}
<span className="text-muted-foreground mx-1">: </span>
</span>
)}
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
toggleNode(path);
}}
className="h-4 w-4 p-0 text-muted-foreground hover:text-foreground hover:bg-transparent"
>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform shrink-0",
isOpen && "rotate-90"
)}
/>
</Button>
<span className="text-muted-foreground">{"{"}</span>
{!isOpen && (
<>
<span className="text-muted-foreground">...</span>
<span className="text-muted-foreground">
{"}"} ({entries.length} {entries.length > 1 ? "items" : "item"})
</span>
{showComma && <span className="text-muted-foreground">,</span>}
</>
)}
</div>
);
return (
<Collapsible open={isOpen} onOpenChange={() => toggleNode(path)}>
{trigger}
<CollapsibleContent className="transition-all duration-200">
<div
className={cn(
"pl-5 border-l",
showColorIndent
? indentColors[level % indentColors.length]
: "border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)]"
)}
>
{entries.map(([key, value], index) => {
const childPath = `${path}.${key}`;
const dataType = getDataType(value);
const isChildCollapsible =
dataType === "object" || dataType === "array";
const isChildOpen =
isChildCollapsible && expandedPaths.has(childPath);
return (
<div
key={key}
className={cn(
"group rounded-md",
!isChildCollapsible && "flex items-start min-h-6",
isChildOpen ? "" : "hover:bg-muted-foreground/20"
)}
>
{isChildCollapsible ? (
<JsonNode
data={value}
level={level + 1}
path={childPath}
expandedPaths={expandedPaths}
toggleNode={toggleNode}
showComma={index < entries.length - 1}
objectKey={key}
truncation={truncation}
showColorIndent={showColorIndent}
collapseOn={collapseOn}
/>
) : (
<>
<span className="text-purple-600 dark:text-purple-400 inline-flex items-center">
{`'${key}'`}
</span>
<span className="text-muted-foreground">: </span>
<JsonNode
data={value}
level={level + 1}
path={childPath}
expandedPaths={expandedPaths}
toggleNode={toggleNode}
showComma={index < entries.length - 1}
truncation={truncation}
showColorIndent={showColorIndent}
collapseOn={collapseOn}
/>
</>
)}
</div>
);
})}
</div>
<div>
<span className="text-muted-foreground">{"}"}</span>
{showComma && <span className="text-muted-foreground">,</span>}
</div>
</CollapsibleContent>
</Collapsible>
);
};
const JsonArray: React.FC<{
objectKey?: string;
data: any[];
level: number;
path: string;
expandedPaths: Set<string>;
toggleNode: (path: string) => void;
showComma?: boolean;
truncation: TruncationSettings;
showColorIndent?: boolean;
collapseOn?: "click" | "doubleClick";
}> = ({
data,
level,
path,
expandedPaths,
toggleNode,
showComma,
objectKey,
truncation,
showColorIndent,
collapseOn
}) => {
const isOpen = expandedPaths.has(path);
const [showAll, setShowAll] = useState(false);
const itemsToShow =
truncation.enabled && !showAll && data.length > truncation.itemsPerArray
? data.slice(0, truncation.itemsPerArray)
: data;
const handleShowMore = () => {
setShowAll(true);
};
const trigger = (
<div
className={cn(
"inline-flex items-center text-left h-6 leading-6 group rounded-sm px-1 -ml-1 w-full cursor-pointer select-none",
isOpen && "hover:bg-muted-foreground/20"
)}
onDoubleClick={
collapseOn === "doubleClick" ? () => toggleNode(path) : undefined
}
onClick={
collapseOn === "doubleClick"
? undefined
: (e) => {
toggleNode(path);
}
}
>
{objectKey && (
<span className="text-purple-600 dark:text-purple-400 inline-flex items-center group">
{`'${objectKey}'`}
<span className="text-muted-foreground mx-1">: </span>
</span>
)}
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
toggleNode(path);
}}
className="h-4 w-4 p-0 text-muted-foreground hover:text-foreground hover:bg-transparent"
>
<ChevronRight
className={cn(
"h-4 w-4 transition-transform shrink-0",
isOpen && "rotate-90"
)}
/>
</Button>
<span className="text-muted-foreground">{"["}</span>
{!isOpen && (
<>
<span className="text-muted-foreground">...</span>
<span className="text-muted-foreground">
{"]"} ({data.length} items)
</span>
{showComma && <span className="text-muted-foreground">,</span>}
</>
)}
</div>
);
return (
<Collapsible open={isOpen} onOpenChange={() => toggleNode(path)}>
{trigger}
<CollapsibleContent className="transition-all duration-200">
<div
className={cn(
"pl-5 border-l",
showColorIndent
? indentColors[level % indentColors.length]
: "border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)]"
)}
>
{itemsToShow.map((item, index) => {
const childPath = `${path}[${index}]`;
const dataType = getDataType(item);
const isChildCollapsible =
dataType === "object" || dataType === "array";
const isChildOpen =
isChildCollapsible && expandedPaths.has(childPath);
return (
<div
key={index}
className={cn(
"group rounded-md",
!isChildCollapsible &&
"flex sm:items-center items-start sm:h-6 h-auto",
isChildOpen ? "" : "hover:bg-muted-foreground/20"
)}
>
<JsonNode
data={item}
level={level + 1}
path={childPath}
expandedPaths={expandedPaths}
toggleNode={toggleNode}
showComma={index < data.length - 1}
truncation={truncation}
showColorIndent={showColorIndent}
collapseOn={collapseOn}
/>
</div>
);
})}
{truncation.enabled && data.length > truncation.itemsPerArray && (
<div className="pl-5">
{!showAll ? (
<Button
variant="secondary"
size="sm"
onClick={handleShowMore}
className="h-auto px-2 py-0.5 text-xs bg-secondary/30 hover:bg-secondary/50 text-muted-foreground hover:text-foreground mt-1"
>
Show {data.length - truncation.itemsPerArray} more items...
</Button>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => setShowAll(false)}
className="h-auto px-2 py-0.5 text-xs bg-secondary/30 hover:bg-secondary/50 text-muted-foreground hover:text-foreground mt-1"
>
Show Less
</Button>
)}
</div>
)}
</div>
<div>
<span className="text-muted-foreground">]</span>
{showComma && <span className="text-muted-foreground">,</span>}
</div>
</CollapsibleContent>
</Collapsible>
);
};
export default JsonViewer;
registry/8starlabs-ui/ui/copy-button.tsx
"use client";
import * as React from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/8starlabs-ui/blocks/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger
} from "@/registry/8starlabs-ui/ui/tooltip";
export function CopyButton({
value,
className,
variant = "ghost",
...props
}: React.ComponentProps<typeof Button> & {
value: string;
src?: string;
}) {
const [hasCopied, setHasCopied] = React.useState(false);
React.useEffect(() => {
if (!hasCopied) {
return;
}
const timer = setTimeout(() => setHasCopied(false), 2000);
return () => clearTimeout(timer);
}, [hasCopied]);
return (
<Tooltip>
<TooltipTrigger
render={
<Button
data-slot="copy-button"
size="icon"
variant={variant}
className={cn(
"absolute top-2 right-2 z-10 size-7 opacity-70 hover:opacity-100 focus-visible:opacity-100",
className
)}
onClick={() => {
navigator.clipboard.writeText(value);
setHasCopied(true);
}}
{...props}
>
<span className="sr-only">Copy</span>
{hasCopied ? <IconCheck /> : <IconCopy />}
</Button>
}
/>
<TooltipContent>
{hasCopied ? "Copied" : "Copy to Clipboard"}
</TooltipContent>
</Tooltip>
);
}
registry/8starlabs-ui/hooks/use-mobile.ts
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}