A simple problem: make sure that the different elements in the app are the same height, as if they were in a table.
Let's start with a sample react app that renders 3 cards with different items (styles are omitted, but at the end they are all flex boxes):
const ItemCard = ({
title,
items,
footerItems,
}: {
title: string;
items: string[];
footerItems: string[];
}) => {
return (
<div className="card">
<h2>{title}</h2>
<div className="separator" />
<div className="items">
{items.map((item) => (
<p>{item}</p>
))}
</div>
<div className="separator" />
<div className="footer">
{footerItems.map((footerItem) => (
<p>{footerItem}</p>
))}
</div>
</div>
);
};
export const App = () => {
return (
<div>
<ItemCard title="Card one" items={['One', 'Two']} footerItems={['One']} />
<ItemCard
title="Card two"
items={['One', 'Two', 'Three', 'Four']}
footerItems={['One', 'Two', 'Three']}
/>
<ItemCard title="Card three" items={['One']} footerItems={['One']} />
</div>
);
};
When you run this app, you will get this result:
The desired result would be something like this:
In order to synchronize the height I came with the following idea: a custom hook that stores the references to all different elements that have to be matched in a {[key: string]: value: array of elements} object, and when there is a change in dependencies, the height of elements gets recalcualted in useLayoutEffect
:
import { MutableRefObject, useLayoutEffect } from 'react';
type Target = MutableRefObject<HTMLElement | null>;
// Store all elements per key, so it is easy to retrieve them
const store: Record<string, Target[]> = {};
// Triggered when useLayoutEffect is executed on any of the components that use useSyncRefHeight hook
const handleResize = (key: string) => {
// get all elements with the same key
const elements = store[key];
if (elements) {
let max = 0;
// find the element with highest clientHeight value
elements.forEach((element) => {
if (element.current && element.current.clientHeight > max) {
max = element.current.clientHeight;
}
});
// update height of all 'joined' elements
elements.forEach((element) => {
if (element.current) {
element.current.style.minHeight = `${max}px`;
}
});
}
};
// Add element to the store when component is mounted and return cleanup function
const add = (key: string, element: Target) => {
// create store if missing
if (!store[key]) {
store[key] = [];
}
store[key].push(element);
// cleanup function
return () => {
const index = store[key].indexOf(element);
if (index > -1) {
store[key].splice(index, 1);
}
};
};
// Receives multiple elements ([key, element] pairs). This way one hook can be used to handle multiple elements
export type UseSyncRefHeightProps = Array<[string, Target]>;
export const useSyncRefHeight = (refs: UseSyncRefHeightProps, deps?: any[]) => {
useLayoutEffect(() => {
// store cleanup functions for each entry
const cleanups: (() => void)[] = [];
refs.forEach(([key, element]) => {
// add element ref to store
cleanups.push(add(key, element));
});
return () => {
// cleanup when component is destroyed
cleanups.forEach((cleanup) => cleanup());
};
}, []);
useLayoutEffect(() => {
// when any of the dependencies changes, update all elements heights
refs.forEach(([key]) => {
handleResize(key);
});
}, deps);
};
By using this hook we can change a bit ItemCard element:
const ItemCard = ({
title,
items,
footerItems,
}: {
title: string;
items: string[];
footerItems: string[];
}) => {
// create ref to the parent container, to only target its children instead of running query on the entire document
const itemsRef = useRef(null);
const footerRef = useRef(null);
// align elements with class items
// deps is an empty array, so it will only be aligned when the component is mounted.
// You can add your dependencies, or remove it to make sure the hook runs at every render
useSyncRefHeight(
[
['items', itemsRef],
['footer', footerRef],
],
// trigger hook when items of footerItems changes, since it may change height
[items, footerItems],
);
return (
<div className="card">
<h2>{title}</h2>
<div className="separator" />
<div className="items" ref={itemsRef}>
{items.map((item) => (
<p>{item}</p>
))}
</div>
<div className="separator" />
<div className="footer" ref={footerRef}>
{footerItems.map((footerItem) => (
<p>{footerItem}</p>
))}
</div>
</div>
);
};
Now, items and footer elements height will be matched across all cards.
Top comments (3)
This is cool. Thank you Oleksandr
If you add a reset like this, and trigger the hook on window width, then it works responsively.
How trigger the hook on window width?
Use something like: usehooks.com/usewindowsize
And then pass width into
useSyncRefHeight
as the 2nd param, like[items, footerItems, size.width],